vbv/server/vbv_lernwelt/learnpath/models.py

544 lines
17 KiB
Python

import re
from typing import Tuple
from django.db import models
from django.utils.text import slugify
from modelcluster.models import ParentalManyToManyField
from wagtail.admin.panels import FieldPanel, PageChooserPanel
from wagtail.fields import RichTextField, StreamField
from wagtail.models import Page
from vbv_lernwelt.assignment.models import AssignmentType
from vbv_lernwelt.core.constants import DEFAULT_RICH_TEXT_FEATURES_WITH_HEADER
from vbv_lernwelt.core.model_utils import find_available_slug
from vbv_lernwelt.course.models import CourseBasePage, CoursePage
from vbv_lernwelt.media_library.content_blocks import LearnMediaBlock
class LearningPath(CourseBasePage):
serialize_field_names = ["children", "course"]
content_panels = Page.content_panels
subpage_types = ["learnpath.Circle", "learnpath.Topic"]
parent_page_types = ["course.CoursePage"]
class Meta:
verbose_name = "Learning Path"
def save(self, clean=True, user=None, log_action=False, **kwargs):
self.slug = find_available_slug(
slugify(f"{self.get_parent().slug}-lp", allow_unicode=True),
ignore_page_id=self.id,
)
super(LearningPath, self).save(clean, user, log_action, **kwargs)
def __str__(self):
return f"{self.title}"
def get_frontend_url(self):
return f"/course/{self.slug.replace('-lp', '')}/learn"
class Topic(CourseBasePage):
serialize_field_names = ["is_visible"]
is_visible = models.BooleanField(default=True)
parent_page_types = ["learnpath.LearningPath"]
panels = [
FieldPanel("title"),
FieldPanel("is_visible"),
]
def save(self, clean=True, user=None, log_action=False, **kwargs):
self.slug = find_slug_with_parent_prefix(self, "topic")
super(Topic, self).save(clean, user, log_action, **kwargs)
def get_admin_display_title(self):
return f"Thema: {self.draft_title}"
def get_frontend_url(self):
return ""
class Meta:
verbose_name = "Topic"
def __str__(self):
return f"{self.title}"
class CourseProfile(models.Model):
code = models.CharField(max_length=255)
order = models.IntegerField(default=999)
def __str__(self) -> str:
return self.code
class Meta:
ordering = [
"order",
]
class CourseProfileToCircle(models.Model):
# this connects the course profile to a circle, because a circle can be in multiple profiles
# todo: to we even need a through model?
pass
class Circle(CourseBasePage):
parent_page_types = ["learnpath.LearningPath"]
subpage_types = [
"learnpath.LearningSequence",
"learnpath.LearningUnit",
"learnpath.LearningContentAssignment",
"learnpath.LearningContentAttendanceCourse",
"learnpath.LearningContentFeedbackUK",
"learnpath.LearningContentFeedbackVV",
"learnpath.LearningContentLearningModule",
"learnpath.LearningContentKnowledgeAssessment",
"learnpath.LearningContentMediaLibrary",
"learnpath.LearningContentPlaceholder",
"learnpath.LearningContentRichText",
"learnpath.LearningContentEdoniqTest",
"learnpath.LearningContentVideo",
"learnpath.LearningContentDocumentList",
]
serialize_field_names = [
"children",
"description",
"goals",
]
description = models.TextField(default="", blank=True)
goals = RichTextField(features=DEFAULT_RICH_TEXT_FEATURES_WITH_HEADER)
profiles = ParentalManyToManyField(CourseProfile, related_name="circles")
# base circles do never belong to a course profile and should also get displayed no matter what profile is chosen
is_base_circle = models.BooleanField(default=False)
# profile = models.ForeignKey(
# ApprovalProfile,
# null=True,
# blank=True,
# on_delete=models.SET_NULL,
# related_name="circles",
# help_text="Zulassungsprofil",
# )
content_panels = Page.content_panels + [
FieldPanel("description"),
FieldPanel("goals"),
FieldPanel("is_base_circle"),
FieldPanel("profiles"),
]
def get_frontend_url(self):
r = re.compile(r"^(?P<coursePart>.+?)-lp-circle-(?P<circlePart>.+?)$")
m = r.match(self.slug)
if m is None:
return "ERROR: could not parse slug"
return f"/course/{m.group('coursePart')}/learn/{m.group('circlePart')}"
def save(self, clean=True, user=None, log_action=False, **kwargs):
self.slug = find_slug_with_parent_prefix(self, "circle")
super(Circle, self).save(clean, user, log_action, **kwargs)
class Meta:
verbose_name = "Circle"
def __str__(self):
return f"{self.title}"
class LearningSequence(CourseBasePage):
serialize_field_names = ["icon"]
parent_page_types = ["learnpath.Circle"]
subpage_types = []
icon = models.CharField(max_length=255, default="it-icon-ls-start")
content_panels = Page.content_panels + [
FieldPanel("icon"),
]
class Meta:
verbose_name = "Learning Sequence"
def __str__(self):
return f"{self.title}"
def get_admin_display_title_html(self):
return f"""
<span style="display: inline-flex; align-items: center; font-size: 1.25rem; font-weight: 700;">
<{self.icon} style="height: 32px; width: 32px;"></{self.icon}>
<span style="margin-left: 8px;">{self.draft_title}</span>
</span>"""
def get_admin_display_title(self):
return f"LS: {self.draft_title}"
def save(self, clean=True, user=None, log_action=False, **kwargs):
self.slug = find_slug_with_parent_prefix(self, "ls")
super(LearningSequence, self).save(clean, user, log_action, **kwargs)
def get_frontend_url(self):
r = re.compile(
r"^(?P<coursePart>.+?)-lp-circle-(?P<circlePart>.+?)-ls-(?P<lsPart>.+?)$"
)
m = r.match(self.slug)
if m is None:
return "ERROR: could not parse slug"
return f"/course/{m.group('coursePart')}/learn/{m.group('circlePart')}#ls-{m.group('lsPart')}"
class LearningUnit(CourseBasePage):
parent_page_types = ["learnpath.Circle"]
subpage_types = []
course_category = models.ForeignKey(
"course.CourseCategory", on_delete=models.SET_NULL, null=True, blank=True
)
title_hidden = models.BooleanField(default=False)
content_panels = Page.content_panels + [
FieldPanel("course_category"),
FieldPanel("title_hidden"),
]
class Meta:
verbose_name = "Learning Unit"
def __str__(self):
return f"{self.title}"
def save(self, clean=True, user=None, log_action=False, **kwargs):
course = None
course_parent_page = self.get_ancestors().exact_type(CoursePage).last()
if course_parent_page:
course = course_parent_page.specific.course
if self.course_category is None and course:
self.course_category = course.coursecategory_set.filter(
general=True
).first()
if self.course_category.general:
self.slug = find_slug_with_parent_prefix(self, "lu")
else:
self.slug = find_slug_with_parent_prefix(
self, "lu", self.course_category.title
)
super(LearningUnit, self).save(clean, user, log_action, **kwargs)
def get_frontend_url_parts(self) -> Tuple[str, str, str]:
"""
Extracts the course, circle and learning unit part from the slug.
:return: Tuple of course, circle and learning unit part
"""
r = re.compile(
r"^(?P<coursePart>.+?)-lp-circle-(?P<circlePart>.+?)-lu-(?P<luPart>.+?)$"
)
m = r.match(self.slug)
if m is None:
ValueError(f"Could not parse slug: {self.slug}")
return m.group("coursePart"), m.group("circlePart"), m.group("luPart")
def get_frontend_url(self):
course, circle, learning_unit = self.get_frontend_url_parts()
return f"/course/{course}/learn/{circle}#lu-{learning_unit}"
def get_evaluate_url(self):
course, circle, learning_unit = self.get_frontend_url_parts()
return f"/course/{course}/learn/{circle}/evaluate/{learning_unit}"
def get_admin_display_title(self):
return f"LE: {self.draft_title}"
@classmethod
def get_serializer_class(cls):
from vbv_lernwelt.learnpath.serializers import LearningUnitSerializer
return LearningUnitSerializer
def get_admin_display_title_html(self):
return f'<span style="font-weight: 700; font-size: 20px;">{self.draft_title}</span>'
class LearningContent(CourseBasePage):
class Meta:
abstract = True
serialize_field_names = [
"minutes",
"description",
"content_url",
"can_user_self_toggle_course_completion",
]
minutes = models.PositiveIntegerField(default=15)
description = RichTextField(blank=True)
content_url = models.TextField(blank=True)
has_course_completion_status = models.BooleanField(default=True)
can_user_self_toggle_course_completion = models.BooleanField(default=False)
content_panels = [
FieldPanel("title", classname="full title"),
FieldPanel("minutes"),
FieldPanel("content_url"),
FieldPanel("description"),
FieldPanel("can_user_self_toggle_course_completion"),
]
def get_admin_display_title(self):
display_title = ""
# if len(self.contents) > 0:
# display_title += f"{self.contents[0].block_type.capitalize()}: "
display_title += self.draft_title
return display_title
def get_admin_display_title_html(self):
return f"""
<span style="display: inline-flex; align-items: center;">
<it-icon-checkbox-unchecked style="height: 24px; width: 24px;"></it-icon-checkbox-unchecked>
<span style="margin-left: 8px;">{self.get_admin_display_title()}</span>
</span>"""
def get_frontend_url(self, course_session_id=None):
r = re.compile(
r"^(?P<coursePart>.+?)-lp-circle-(?P<circlePart>.+?)-lc-(?P<lcPart>.+)$"
)
m = r.match(self.slug)
if m is None:
return "ERROR: could not parse slug"
url = f"/course/{m.group('coursePart')}/learn/{m.group('circlePart')}/{m.group('lcPart')}"
if course_session_id:
url += f"?courseSessionId={course_session_id}"
return url
def get_parent_circle(self):
try:
return self.get_ancestors().exact_type(Circle).first()
except Exception:
# noop
pass
return None
def save(self, clean=True, user=None, log_action=False, **kwargs):
self.slug = find_slug_with_parent_prefix(self, "lc")
super().save(**kwargs)
class LearningContentAttendanceCourse(LearningContent):
"""
Präsenzkurs
"""
parent_page_types = ["learnpath.Circle"]
subpage_types = []
class LearningContentVideo(LearningContent):
parent_page_types = ["learnpath.Circle"]
subpage_types = []
can_user_self_toggle_course_completion = models.BooleanField(default=True)
class LearningContentPlaceholder(LearningContent):
parent_page_types = ["learnpath.Circle"]
subpage_types = []
can_user_self_toggle_course_completion = models.BooleanField(default=True)
class LearningContentFeedbackUK(LearningContent):
parent_page_types = ["learnpath.Circle"]
subpage_types = []
can_user_self_toggle_course_completion = models.BooleanField(default=False)
class LearningContentFeedbackVV(LearningContent):
parent_page_types = ["learnpath.Circle"]
subpage_types = []
can_user_self_toggle_course_completion = models.BooleanField(default=False)
class LearningContentLearningModule(LearningContent):
parent_page_types = ["learnpath.Circle"]
subpage_types = []
can_user_self_toggle_course_completion = models.BooleanField(default=True)
class LearningContentKnowledgeAssessment(LearningContent):
parent_page_types = ["learnpath.Circle"]
subpage_types = []
can_user_self_toggle_course_completion = models.BooleanField(default=True)
class LearningContentMediaLibrary(LearningContent):
parent_page_types = ["learnpath.Circle"]
subpage_types = []
can_user_self_toggle_course_completion = models.BooleanField(default=True)
class LearningContentEdoniqTest(LearningContent):
serialize_field_names = LearningContent.serialize_field_names + [
"checkbox_text",
"has_extended_time_test",
"content_assignment_id",
]
parent_page_types = ["learnpath.Circle"]
subpage_types = []
checkbox_text = models.TextField(blank=True)
test_url = models.TextField(blank=True)
extended_time_test_url = models.TextField(blank=True)
# Sequenz ID von Edoniq
edoniq_sequence_id = models.CharField(max_length=255, blank=True, default="")
edoniq_extended_sequence_id = models.CharField(
max_length=255, blank=True, default=""
)
# Kursfreigaben ID von Edoniq
edoniq_course_release_id = models.CharField(max_length=255, blank=True, default="")
edoniq_extended_course_release_id = models.CharField(
max_length=255, blank=True, default=""
)
content_panels = LearningContent.content_panels + [
FieldPanel("checkbox_text", classname="Text"),
FieldPanel("test_url", classname="Text"),
FieldPanel("edoniq_course_release_id", classname="Text"),
FieldPanel("edoniq_sequence_id", classname="Text"),
FieldPanel("extended_time_test_url", classname="Text"),
FieldPanel("edoniq_extended_course_release_id", classname="Text"),
FieldPanel("edoniq_extended_sequence_id", classname="Text"),
PageChooserPanel("content_assignment", "assignment.Assignment"),
]
content_assignment = models.ForeignKey(
"assignment.Assignment",
on_delete=models.PROTECT,
)
@property
def has_extended_time_test(self):
return bool(self.extended_time_test_url)
@classmethod
def get_serializer_class(cls):
from vbv_lernwelt.learnpath.serializers import (
LearningContentEdoniqTestSerializer,
)
return LearningContentEdoniqTestSerializer
class LearningContentRichText(LearningContent):
text = RichTextField(blank=True, features=DEFAULT_RICH_TEXT_FEATURES_WITH_HEADER)
can_user_self_toggle_course_completion = models.BooleanField(default=True)
parent_page_types = ["learnpath.Circle"]
serialize_field_names = LearningContent.serialize_field_names + [
"text",
]
subpage_types = []
content_panels = LearningContent.content_panels + [
FieldPanel("text", classname="Text"),
]
class LearningContentAssignment(LearningContent):
serialize_field_names = LearningContent.serialize_field_names + [
"content_assignment_id",
"assignment_type",
]
parent_page_types = ["learnpath.Circle"]
subpage_types = []
content_assignment = models.ForeignKey(
"assignment.Assignment",
on_delete=models.PROTECT,
)
assignment_type = models.CharField(
max_length=50,
choices=[(tag.name, tag.name) for tag in AssignmentType],
default=AssignmentType.CASEWORK.name,
)
content_panels = [
FieldPanel("title", classname="full title"),
FieldPanel("minutes"),
PageChooserPanel("content_assignment", "assignment.Assignment"),
FieldPanel("content_url"),
FieldPanel("description"),
]
def save(self, clean=True, user=None, log_action=False, **kwargs):
self.assignment_type = self.content_assignment.assignment_type
super().save(**kwargs)
def __str__(self):
return f"{self.id} - {self.title}"
@classmethod
def get_serializer_class(cls):
from vbv_lernwelt.learnpath.serializers import (
LearningContentAssignmentSerializer,
)
return LearningContentAssignmentSerializer
class LearningContentDocumentList(LearningContent):
can_user_self_toggle_course_completion = models.BooleanField(default=True)
serialize_field_names = LearningContent.serialize_field_names + [
"documents",
]
parent_page_types = ["learnpath.Circle"]
subpage_types = []
documents = StreamField(
[
("document", LearnMediaBlock()),
],
use_json_field=True,
blank=True,
)
content_panels = [
FieldPanel("title", classname="full title"),
FieldPanel("minutes"),
FieldPanel("description"),
FieldPanel("documents"),
]
def find_slug_with_parent_prefix(page, type_prefix, slug_postfix=None):
parent_slug = page.get_ancestors().exact_type(LearningPath, Circle).last().slug
if parent_slug:
slug_prefix = f"{parent_slug}-{type_prefix}"
else:
slug_prefix = type_prefix
if slug_postfix is None:
slug_postfix = page.title
return find_available_slug(
slugify(f"{slug_prefix}-{slug_postfix}", allow_unicode=True),
ignore_page_id=page.id,
)