Add course

This commit is contained in:
cuom1999 2024-02-19 17:00:44 -06:00
parent d409f0e9b4
commit 83579891b9
27 changed files with 1308 additions and 484 deletions

View file

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

View file

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

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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
View 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;
}
}

View file

@ -17,6 +17,7 @@
background: #fff;
border: 1px solid DarkGray;
font-family: $monospace-fonts;
font-size: 15px;
}
.wmd-preview {

View file

@ -15,4 +15,5 @@
@import "organization";
@import "ticket";
@import "pagedown_widget";
@import "dmmd-preview";
@import "dmmd-preview";
@import "course";

View file

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

View 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 %}

View 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 %}

View 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 %}

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

View 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 %}

View file

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

View file

@ -14,10 +14,5 @@
$(this).parent().submit();
}
});
$('#control-panel a').on('click', function(e) {
e.preventDefault();
navigateTo($(this));
})
});
</script>

View file

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

View file

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

View file

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