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+)/", 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(),

View file

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

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

View file

@ -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
@classmethod def get_absolute_url(self):
def is_editable_by(course, profile): return reverse("course_detail", args=(self.slug,))
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
@classmethod @classmethod
def is_accessible_by(cls, course, profile): def is_editable_by(cls, course, profile):
userqueryset = CourseRole.objects.filter(course=course, user=profile) try:
if userqueryset.exists(): course_role = CourseRole.objects.get(course=course, user=profile)
return True return course_role.role in EDITABLE_ROLES
else: except CourseRole.DoesNotExist:
return False return False
@classmethod @classmethod
def get_students(cls, course): def is_accessible_by(cls, course, profile):
return CourseRole.objects.filter(course=course, role="ST").values("user") 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 @classmethod
def get_assistants(cls, course): def get_accessible_courses(cls, profile):
return CourseRole.objects.filter(course=course, role="AS").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_teachers(cls, course): course_roles = CourseRole.objects.filter(course=self, role=role).select_related(
return CourseRole.objects.filter(course=course, role="TE").values("user") "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 @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"))

View file

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

View file

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

View file

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

View file

@ -19,15 +19,14 @@ 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, defaults={"knowledge_points": 0, "thinking_points": 0},
defaults={"knowledge_points": 0, "thinking_points": 0}, )
) vote.knowledge_points = knowledge_points
vote.knowledge_points = knowledge_points vote.thinking_points = thinking_points
vote.thinking_points = thinking_points vote.feedback = feedback
vote.feedback = feedback vote.types.set(types)
vote.types.set(types) vote.save()
vote.save()
return JsonResponse({}) 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 key = `oj-content-${window.location.href}`;
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
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; 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 {

View file

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

View file

@ -1,12 +1,5 @@
<!DOCTYPE html> {% extends "two-column-content.html" %}
<html lang="en">
<head> {% block left_sidebar %}
<meta charset="UTF-8"> {% include "course/left_sidebar.html" %}
<meta http-equiv="X-UA-Compatible" content="IE=edge"> {% endblock %}
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Courses</title>
</head>
<body>
</body>
</html>

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> {% 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>

View file

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

View file

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

View file

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

View file

@ -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() {
e.preventDefault(); if ($(this).attr('target') !== '_blank') {
navigateTo($(this)); $(this).on('click', function(e) {
}) e.preventDefault();
navigateTo($(this));
});
}
});
} }
} }