Merge branch 'feature/mediathek-server' into feature/mediathek-frontend

This commit is contained in:
Daniel Egger 2022-09-23 16:17:02 +02:00
commit 8941f4ad24
47 changed files with 744 additions and 272 deletions

View File

@ -4,6 +4,7 @@ import type { LearningContent, LearningSequence } from '@/types'
import { useCircleStore } from '@/stores/circle'
import { computed } from 'vue'
import _ from 'lodash'
import { humanizeDuration } from '@/utils/humanizeDuration'
const props = defineProps<{
learningSequence: LearningSequence
@ -73,14 +74,14 @@ const learningSequenceBorderClass = computed(() => {
<h3 class="text-xl font-semibold">
{{ learningSequence.title }}
</h3>
<div>{{ learningSequence.minutes }} Minuten</div>
<div>{{ humanizeDuration(learningSequence.minutes) }}</div>
</div>
<div class="bg-white px-4 lg:px-6 border border-gray-500" :class="learningSequenceBorderClass">
<div v-for="learningUnit in learningSequence.learningUnits" :key="learningUnit.id" class="pt-3 lg:pt-6">
<div class="pb-3 lg:pg-6 flex gap-4 text-blue-900" v-if="learningUnit.title">
<div class="font-semibold">{{ learningUnit.title }}</div>
<div>{{ learningUnit.minutes }} Minuten</div>
<div>{{ humanizeDuration(learningUnit.minutes) }}</div>
</div>
<div

View File

@ -16,7 +16,7 @@ def main():
)
response = client.get(
'http://localhost:8000/api/learnpath/page/unit-test-lernpfad/',
'http://localhost:8000/api/course/page/unit-test-lernpfad/',
)
print(response.status_code)
print(response.json())

View File

@ -19,7 +19,7 @@ export const useLearningPathStore = defineStore({
if (this.learningPath && !reload) {
return this.learningPath;
}
const learningPathData = await itGet(`/api/learnpath/page/${slug}/`);
const learningPathData = await itGet(`/api/course/page/${slug}/`);
const completionData = await itGet(`/api/completion/learning_path/${learningPathData.translation_key}/`);
if (!learningPathData) {

View File

@ -0,0 +1,13 @@
import { expect, test } from 'vitest'
import { humanizeDuration } from '../humanizeDuration'
test('format duration for humans', () => {
expect(humanizeDuration(1)).toBe('1 Minute')
expect(humanizeDuration(15)).toBe('15 Minuten')
expect(humanizeDuration(42)).toBe('45 Minuten')
expect(humanizeDuration(60)).toBe('1 Stunde')
expect(humanizeDuration(122)).toBe('2 Stunden')
expect(humanizeDuration(120)).toBe('2 Stunden')
expect(humanizeDuration(132)).toBe('2 Stunden 15 Minuten')
expect(humanizeDuration(632)).toBe('10 Stunden')
})

View File

@ -0,0 +1,29 @@
function pluralize(text: string, count: number) {
if (count === 1) {
return text;
}
return text + 'n';
}
export function humanizeDuration(minutes: number) {
const hours = Math.floor(minutes / 60)
const remainingMinutes = minutes % 60
if (hours === 0 && minutes < 16) {
return pluralize(`${remainingMinutes} Minute`, remainingMinutes)
}
// Remaining minutes are rounded to 15 mins
const roundToMinutes = 15
const roundedMinutes = Math.round((minutes % 60) / roundToMinutes) * roundToMinutes
const hoursString = hours > 0 ? pluralize(`${hours} Stunde`, hours) : ''
const showMinutesUpToHours = 10
const minutesString = roundedMinutes > 0 && hours < showMinutesUpToHours
? pluralize(`${roundedMinutes} Minute`, roundedMinutes) : ''
const delimiter = hoursString && minutesString ? ' ' : ''
return `${hoursString}${delimiter}${minutesString}`
}

View File

@ -10,6 +10,7 @@ import { useCircleStore } from '@/stores/circle'
import { useAppStore } from '@/stores/app'
import { useRoute } from 'vue-router'
import _ from 'lodash'
import { humanizeDuration } from '@/utils/humanizeDuration'
const route = useRoute()
@ -28,7 +29,7 @@ const circleStore = useCircleStore()
const duration = computed(() => {
if (circleStore.circle) {
const minutes = _.sumBy(circleStore.circle.learningSequences, 'minutes')
return `${minutes} Minuten`
return humanizeDuration(minutes)
}
return ''

View File

@ -16,7 +16,7 @@ const userStore = useUserStore()
<div class="mt-8 p-8 break-words bg-white max-w-xl">
<h3>Versicherungsvermittler/in</h3>
<div class="mt-4">
<router-link class="btn-blue" to="/learn/versicherungsvermittlerin"> Weiter geht's </router-link>
<router-link class="btn-blue" to="/learn/versicherungsvermittlerin-lp"> Weiter geht's </router-link>
</div>
</div>
</main>

View File

@ -62,6 +62,7 @@ if [ "$SKIP_SETUP" = false ]; then
python3 server/manage.py createcachetable --settings="$DJANGO_SETTINGS_MODULE"
python3 server/manage.py migrate --settings="$DJANGO_SETTINGS_MODULE"
python3 server/manage.py create_default_users --settings="$DJANGO_SETTINGS_MODULE"
python3 server/manage.py create_default_courses --settings="$DJANGO_SETTINGS_MODULE"
python3 server/manage.py create_default_learning_path --settings="$DJANGO_SETTINGS_MODULE"
python3 server/manage.py create_default_media_library --settings="$DJANGO_SETTINGS_MODULE"

View File

@ -103,9 +103,10 @@ THIRD_PARTY_APPS = [
LOCAL_APPS = [
"vbv_lernwelt.core",
"vbv_lernwelt.sso",
"vbv_lernwelt.course",
"vbv_lernwelt.learnpath",
"vbv_lernwelt.completion",
"vbv_lernwelt.media_library",
"vbv_lernwelt.completion",
]
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
@ -514,7 +515,7 @@ if "django_redis.cache.RedisCache" in env("IT_DJANGO_CACHE_BACKEND", default="")
},
}
CACHES["learning_path_cache"] = {
CACHES["api_page_cache"] = {
"BACKEND": "django.core.cache.backends.db.DatabaseCache",
"LOCATION": "django_cache_learning_path",
}

View File

@ -17,7 +17,7 @@ from vbv_lernwelt.core.views import (
rate_limit_exceeded_view,
permission_denied_view,
check_rate_limit, cypress_reset_view, vue_home, vue_login, me_user_view, vue_logout, generate_web_component_icons, )
from vbv_lernwelt.learnpath.views import page_api_view
from vbv_lernwelt.course.views import page_api_view
def raise_example_error(request):
@ -47,8 +47,8 @@ urlpatterns = [
# core
re_path(r"server/core/icons/$", generate_web_component_icons, name="generate_web_component_icons"),
# learnpath
path(r"api/learnpath/page/<slug:slug>/", page_api_view, name="page_api_view"),
# course
path(r"api/course/page/<slug:slug>/", page_api_view, name="page_api_view"),
# completion
path(r"api/completion/circle/<uuid:circle_key>/", request_circle_completion, name="request_circle_completion"),

View File

@ -1,4 +1,4 @@
# Generated by Django 3.2.13 on 2022-07-04 09:58
# Generated by Django 3.2.13 on 2022-09-19 14:37
from django.conf import settings
from django.db import migrations, models
@ -22,8 +22,8 @@ class Migration(migrations.Migration):
('updated_at', models.DateTimeField(auto_now=True)),
('page_key', models.UUIDField()),
('page_type', models.CharField(blank=True, default='', max_length=255)),
('circle_key', models.UUIDField()),
('learning_path_key', models.UUIDField()),
('circle_key', models.UUIDField(blank=True, default='')),
('learning_path_key', models.UUIDField(blank=True, default='')),
('completed', models.BooleanField(default=False)),
('json_data', models.JSONField(blank=True, default=dict)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),

View File

@ -0,0 +1,34 @@
from wagtail.models import Page
def find_available_slug(requested_slug, ignore_page_id=None):
"""
Finds an available slug within the specified parent.
If the requested slug is not available, this adds a number on the end, for example:
- 'requested-slug'
- 'requested-slug-1'
- 'requested-slug-2'
And so on, until an available slug is found.
The `ignore_page_id` keyword argument is useful for when you are updating a page,
you can pass the page being updated here so the page's current slug is not
treated as in use by another page.
"""
pages = Page.objects.filter(slug__startswith=requested_slug)
if ignore_page_id:
pages = pages.exclude(id=ignore_page_id)
existing_slugs = set(pages.values_list("slug", flat=True))
slug = requested_slug
number = 1
while slug in existing_slugs:
slug = requested_slug + "-" + str(number)
number += 1
return slug

View File

View File

@ -0,0 +1,3 @@
from django.contrib import admin
# Register your models here.

View File

@ -0,0 +1,13 @@
from django.apps import AppConfig
class CourseConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'vbv_lernwelt.course'
def ready(self):
try:
# pylint: disable=unused-import,import-outside-toplevel
import vbv_lernwelt.course.signals # noqa F401
except ImportError:
pass

View File

@ -0,0 +1 @@
COURSE_VERSICHERUNGSVERMITTLERIN = -1

View File

@ -0,0 +1,45 @@
import wagtail_factories
from django.conf import settings
from wagtail.models import Site
from vbv_lernwelt.course.consts import COURSE_VERSICHERUNGSVERMITTLERIN
from vbv_lernwelt.course.factories import CoursePageFactory
def create_versicherungsvermittlerin_with_categories(apps=None, schema_editor=None):
if apps is not None:
Course = apps.get_model('course', 'Course')
CourseCategory = apps.get_model('course', 'CourseCategory')
else:
# pylint: disable=import-outside-toplevel
from vbv_lernwelt.course.models import Course, CourseCategory
course, _ = Course.objects.get_or_create(
id=COURSE_VERSICHERUNGSVERMITTLERIN,
name='Versicherungsvermittler/in',
category_name='Handlungsfeld'
)
CourseCategory.objects.get_or_create(course=course, name='Allgemein', general=True)
for cat in [
'Fahrzeug', 'Reisen', 'Einkommensicherung', 'Gesundheit', 'Haushalt', 'Sparen',
'Pensionierung', 'KMU', 'Wohneigentum', 'Rechtsstreitigkeiten', 'Erben / Vererben',
'Selbständigkeit',
]:
CourseCategory.objects.get_or_create(course=course, name=cat)
# create default course page
site = Site.objects.filter(is_default_site=True).first()
if not site:
site = wagtail_factories.SiteFactory(is_default_site=True)
if settings.APP_ENVIRONMENT == 'development':
site.port = 8000
site.save()
course_page = CoursePageFactory(
title="Versicherungsvermittler/in",
parent=site.root_page,
course=course,
)

View File

@ -0,0 +1,19 @@
import wagtail_factories
from factory.django import DjangoModelFactory
from vbv_lernwelt.course.models import CoursePage, Course
class CourseFactory(DjangoModelFactory):
class Meta:
model = Course
name = 'Versicherungsvermittler/in'
category_name = 'Handlungsfeld'
class CoursePageFactory(wagtail_factories.PageFactory):
title = "Versicherungsvermittler/in"
class Meta:
model = CoursePage

View File

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
#
# Iterativ GmbH
# http://www.iterativ.ch/
#
# Copyright (c) 2015 Iterativ GmbH. All rights reserved.
#
# Created on 2022-03-31
# @author: lorenz.padberg@iterativ.ch

View File

@ -0,0 +1,8 @@
import djclick as click
from vbv_lernwelt.course.creators import create_versicherungsvermittlerin_with_categories
@click.command()
def command():
create_versicherungsvermittlerin_with_categories()

View File

@ -0,0 +1,47 @@
# Generated by Django 3.2.13 on 2022-09-19 14:57
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
initial = True
dependencies = [
('wagtailcore', '0069_log_entry_jsonfield'),
]
operations = [
migrations.CreateModel(
name='Course',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, verbose_name='Titel')),
('category_name', models.CharField(default='Kategorie', max_length=255, verbose_name='Kategorie-Name')),
],
options={
'verbose_name': 'Lerngang',
},
),
migrations.CreateModel(
name='CoursePage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.course')),
],
options={
'verbose_name': 'Lerngang-Seite',
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='CourseCategory',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(blank=True, max_length=255, verbose_name='Titel')),
('general', models.BooleanField(default=False, verbose_name='Allgemein')),
('course', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.course')),
],
),
]

View File

@ -0,0 +1,43 @@
from django.db import models
from django.utils.text import slugify
from django.utils.translation import gettext_lazy as _
from wagtail.models import Page
from vbv_lernwelt.core.model_utils import find_available_slug
class Course(models.Model):
name = models.CharField(_('Titel'), max_length=255)
category_name = models.CharField(_('Kategorie-Name'), max_length=255, default='Kategorie')
class Meta:
verbose_name = _("Lerngang")
def __str__(self):
return f"{self.name}"
class CourseCategory(models.Model):
# Die Handlungsfelder im "Versicherungsvermittler/in"
name = models.CharField(_('Titel'), max_length=255, blank=True)
course = models.ForeignKey('course.Course', on_delete=models.CASCADE)
general = models.BooleanField(_('Allgemein'), default=False)
def __str__(self):
return f"{self.course} / {self.name}"
class CoursePage(Page):
content_panels = Page.content_panels
subpage_types = ['learnpath.LearningPath', 'media_library.MediaLibraryPage']
course = models.ForeignKey('course.Course', on_delete=models.CASCADE)
class Meta:
verbose_name = _("Lerngang-Seite")
def full_clean(self, *args, **kwargs):
self.slug = find_available_slug(slugify(self.title, allow_unicode=True))
super(CoursePage, self).full_clean(*args, **kwargs)
def __str__(self):
return f"{self.title}"

View File

@ -0,0 +1,15 @@
from rest_framework import serializers
from vbv_lernwelt.course.models import CourseCategory, Course
class CourseSerializer(serializers.ModelSerializer):
class Meta:
model = Course
fields = ['id', 'name', 'category_name']
class CourseCategorySerializer(serializers.ModelSerializer):
class Meta:
model = CourseCategory
fields = ['id', 'name', 'general',]

View File

@ -0,0 +1,16 @@
import structlog
from django.core.cache import caches
from django.db.models.signals import post_delete, post_save
from wagtail.models import Page
logger = structlog.get_logger(__name__)
def invalidate_api_page_cache(sender, **kwargs):
logger.debug('invalidate api_page_cache', label='api_page_cache')
caches['api_page_cache'].clear()
for subclass in Page.__subclasses__():
post_save.connect(invalidate_api_page_cache, subclass)
post_delete.connect(invalidate_api_page_cache, subclass)

View File

@ -0,0 +1,18 @@
import structlog
from rest_framework.decorators import api_view
from rest_framework.response import Response
from wagtail.models import Page
logger = structlog.get_logger(__name__)
@api_view(['GET'])
# @cache_page(60 * 60 * 8, cache="api_page_cache")
def page_api_view(request, slug):
try:
page = Page.objects.get(slug=slug, locale__language_code='de-CH')
serializer = page.specific.get_serializer_class()(page.specific)
return Response(serializer.data)
except Exception as e:
logger.error(e)
return Response({"error": str(e)}, status=404)

View File

@ -5,6 +5,8 @@ from wagtail.models import Site, Page, Locale
from wagtail_localize.models import LocaleSynchronization
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.course.consts import COURSE_VERSICHERUNGSVERMITTLERIN
from vbv_lernwelt.course.models import CoursePage
from vbv_lernwelt.learnpath.models import LearningPath, Topic, Circle, LearningSequence, LearningContent, LearningUnit, \
LearningUnitQuestion
from vbv_lernwelt.learnpath.tests.learning_path_factories import LearningPathFactory, TopicFactory, CircleFactory, \
@ -340,7 +342,11 @@ def create_default_learning_path(user=None, skip_locales=True):
# create_default_competences()
lp = LearningPathFactory(title="Versicherungsvermittler/in", parent=site.root_page)
course_page = CoursePage.objects.get(course_id=COURSE_VERSICHERUNGSVERMITTLERIN)
lp = LearningPathFactory(
title="Lernpfad",
parent=course_page,
)
TopicFactory(title="Basis", is_visible=False, parent=lp)

View File

@ -1,10 +1,10 @@
import djclick as click
from vbv_lernwelt.learnpath.create_default_learning_path import create_default_learning_path
from vbv_lernwelt.learnpath.tests.create_simple_test_learning_path import create_simple_test_learning_path
@click.command()
def command():
create_default_learning_path(skip_locales=True)
create_simple_test_learning_path(skip_locales=True)
# FIXME: readd
# create_simple_test_learning_path(skip_locales=True)

View File

@ -1,8 +1,7 @@
# Generated by Django 3.2.13 on 2022-06-22 15:48
# Generated by Django 3.2.13 on 2022-09-19 15:05
from django.db import migrations, models
import django.db.models.deletion
import modelcluster.fields
import wagtail.blocks
import wagtail.fields
import wagtail.images.blocks
@ -31,34 +30,12 @@ class Migration(migrations.Migration):
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='Competence',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
('category_short', models.CharField(default='', max_length=3)),
('name', models.CharField(max_length=2048)),
],
options={
'verbose_name': 'Competence',
},
),
migrations.CreateModel(
name='CompetencePage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
],
options={
'verbose_name': 'Learning Path',
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='LearningContent',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('minutes', models.PositiveIntegerField(default=15)),
('contents', wagtail.fields.StreamField([('video', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock()), ('url', wagtail.blocks.URLBlock())])), ('web_based_training', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock()), ('url', wagtail.blocks.URLBlock())])), ('podcast', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock()), ('url', wagtail.blocks.URLBlock())])), ('competence', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock())])), ('exercise', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock())])), ('document', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock())])), ('knowledge', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock())]))], use_json_field=None)),
('contents', wagtail.fields.StreamField([('video', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock()), ('url', wagtail.blocks.URLBlock())])), ('web_based_training', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock()), ('url', wagtail.blocks.URLBlock())])), ('podcast', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock()), ('url', wagtail.blocks.URLBlock())])), ('competence', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock())])), ('exercise', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock()), ('url', wagtail.blocks.URLBlock())])), ('document', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock())])), ('knowledge', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock())]))], use_json_field=None)),
],
options={
'verbose_name': 'Learning Content',
@ -117,21 +94,4 @@ class Migration(migrations.Migration):
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='FullfillmentCriteria',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('sort_order', models.IntegerField(blank=True, editable=False, null=True)),
('name', models.CharField(max_length=2048)),
('competence', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, to='learnpath.competence')),
],
options={
'verbose_name': 'Fullfillment Criteria',
},
),
migrations.AddField(
model_name='competence',
name='competence_page',
field=modelcluster.fields.ParentalKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='competences', to='learnpath.competencepage'),
),
]

View File

@ -1,20 +0,0 @@
# Generated by Django 3.2.13 on 2022-08-24 14:47
from django.db import migrations
import wagtail.blocks
import wagtail.fields
class Migration(migrations.Migration):
dependencies = [
('learnpath', '0001_initial'),
]
operations = [
migrations.AlterField(
model_name='learningcontent',
name='contents',
field=wagtail.fields.StreamField([('video', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock()), ('url', wagtail.blocks.URLBlock())])), ('web_based_training', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock()), ('url', wagtail.blocks.URLBlock())])), ('podcast', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock()), ('url', wagtail.blocks.URLBlock())])), ('competence', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock())])), ('exercise', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock()), ('url', wagtail.blocks.URLBlock())])), ('document', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock())])), ('knowledge', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock())]))], use_json_field=None),
),
]

View File

@ -7,6 +7,7 @@ from wagtail.fields import StreamField
from wagtail.images.blocks import ImageChooserBlock
from wagtail.models import Page, Orderable
from vbv_lernwelt.core.model_utils import find_available_slug
from vbv_lernwelt.learnpath.models_learning_unit_content import WebBasedTrainingBlock, VideoBlock, PodcastBlock, \
CompetenceBlock, ExerciseBlock, DocumentBlock, KnowledgeBlock
from vbv_lernwelt.learnpath.serializer_helpers import get_it_serializer_class
@ -17,12 +18,13 @@ class LearningPath(Page):
content_panels = Page.content_panels
subpage_types = ['learnpath.Circle', 'learnpath.Topic']
parent_page_types = ['course.CoursePage']
class Meta:
verbose_name = "Learning Path"
def full_clean(self, *args, **kwargs):
self.slug = find_available_slug(slugify(self.title, allow_unicode=True))
self.slug = find_available_slug(slugify(f"{self.get_parent().slug}-lp", allow_unicode=True))
super(LearningPath, self).full_clean(*args, **kwargs)
def __str__(self):
@ -268,36 +270,3 @@ def find_slug_with_parent_prefix(page, type_prefix):
slug_prefix = type_prefix
return find_available_slug(slugify(f'{slug_prefix}-{page.title}', allow_unicode=True))
def find_available_slug(requested_slug, ignore_page_id=None):
"""
Finds an available slug within the specified parent.
If the requested slug is not available, this adds a number on the end, for example:
- 'requested-slug'
- 'requested-slug-1'
- 'requested-slug-2'
And so on, until an available slug is found.
The `ignore_page_id` keyword argument is useful for when you are updating a page,
you can pass the page being updated here so the page's current slug is not
treated as in use by another page.
"""
pages = Page.objects.filter(slug__startswith=requested_slug)
if ignore_page_id:
pages = pages.exclude(id=ignore_page_id)
existing_slugs = set(pages.values_list("slug", flat=True))
slug = requested_slug
number = 1
while slug in existing_slugs:
slug = requested_slug + "-" + str(number)
number += 1
return slug

View File

@ -1,6 +1,8 @@
import wagtail.api.v2.serializers as wagtail_serializers
from rest_framework.fields import SerializerMethodField
from vbv_lernwelt.course.models import CoursePage
from vbv_lernwelt.course.serializers import CourseCategorySerializer, CourseSerializer
from vbv_lernwelt.learnpath.utils import get_wagtail_type
@ -17,6 +19,8 @@ class ItTypeField(wagtail_serializers.TypeField):
class ItBaseSerializer(wagtail_serializers.BaseSerializer):
type = ItTypeField(read_only=True)
children = SerializerMethodField()
course = SerializerMethodField()
course_category = CourseCategorySerializer(read_only=True)
meta_fields = []
@ -30,6 +34,15 @@ class ItBaseSerializer(wagtail_serializers.BaseSerializer):
children = _get_children(self.descendants, obj)
return [c.specific.get_serializer_class()(c.specific, descendants=self.descendants).data for c in children]
def get_course(self, obj):
if hasattr(obj, 'course'):
return CourseSerializer(obj.course).data
else:
course_parent_page = obj.get_ancestors().exact_type(CoursePage).last()
if course_parent_page:
return CourseSerializer(course_parent_page.specific.course).data
return ''
def _get_descendants(pages, obj):
return [c for c in pages if c.path.startswith(obj.path) and c.depth >= obj.depth]

View File

@ -1,16 +0,0 @@
import structlog
from django.core.cache import caches
from django.db.models.signals import post_delete, post_save
from wagtail.models import Page
logger = structlog.get_logger(__name__)
def invalidate_learning_path_cache(sender, **kwargs):
logger.debug('invalidate learning_path_cache', label='learning_path_cache')
caches['learning_path_cache'].clear()
for subclass in Page.__subclasses__():
post_save.connect(invalidate_learning_path_cache, subclass)
post_delete.connect(invalidate_learning_path_cache, subclass)

View File

@ -18,7 +18,7 @@ class TestRetrieveLearingPathContents(APITestCase):
def test_get_learnpathPage(self):
learning_path = LearningPath.objects.get(slug='unit-test-lernpfad')
response = self.client.get('/api/learnpath/page/unit-test-lernpfad/')
response = self.client.get('/api/course/page/unit-test-lernpfad/')
print(response)
self.assertEqual(response.status_code, 200)
data = response.json()

View File

@ -1,23 +0,0 @@
# Create your views here.
import structlog
from django.views.decorators.cache import cache_page
from rest_framework.decorators import api_view
from rest_framework.response import Response
from wagtail.models import Page
logger = structlog.get_logger(__name__)
@api_view(['GET'])
@cache_page(60 * 60 * 8, cache="learning_path_cache")
def page_api_view(request, slug):
try:
page = Page.objects.get(slug=slug, locale__language_code='de-CH')
serializer = page.specific.get_serializer_class()(page.specific)
return Response(serializer.data)
except Exception as e:
logger.error(e)
return Response({"error": str(e)}, status=404)

View File

@ -0,0 +1,61 @@
from django.db import models
from wagtail import blocks
from wagtail.admin.panels import FieldPanel
from wagtail.documents.blocks import DocumentChooserBlock
from wagtail.snippets.models import register_snippet
@register_snippet
class MediaLibraryContent(models.Model):
title = models.TextField()
description = models.TextField()
link_display_text = models.CharField(max_length=255)
# TODO: Revisions only work with wagtail 4.0, can not migrate since wagtail localize is not ready yet.
# _revisions = GenericRelation("wagtailcore.Revision", related_query_name="media_library_content")
panels = [
FieldPanel('title'),
FieldPanel('description'),
FieldPanel('link_display_text'),
]
@property
def revisions(self):
return self._revisions
class AnchorBlock(blocks.PageChooserBlock):
"""
Verankerung im Lernpfad. Link to a Learning Content.
"""
page_type = 'learnpath.LearningUnit'
class LinkBlock(blocks.StructBlock):
title = blocks.TextBlock(blank=False, null=False)
description = blocks.TextBlock(default='')
link_display_text = blocks.CharBlock(max_length=255, default='Link öffnen')
url = blocks.URLBlock()
class CrossReferenceBlock(blocks.StructBlock):
title = models.TextField(blank=False, null=False)
description = blocks.TextBlock(default='')
link_display_text = blocks.CharBlock(max_length=255, default='Link öffnen')
category = blocks.PageChooserBlock(page_type='media_library.MediaCategoryPage')
class MediaContentCollection(blocks.StructBlock):
"""
Lernmedien, Links, Querverweise, Verankerung
"""
title = blocks.TextBlock()
contents = blocks.StreamBlock([
('Links', LinkBlock()),
('Documents', DocumentChooserBlock()),
('Ankers', AnchorBlock()),
('CrossReference', CrossReferenceBlock())
])
class Meta:
icon = 'link'

View File

@ -3,6 +3,7 @@ import os
import factory
from wagtail.core.models import Collection
from vbv_lernwelt.course.models import Course
from vbv_lernwelt.media_library.models import LibraryDocument
from vbv_lernwelt.media_library.tests.media_library_factories import LibraryDocumentFactory
@ -11,15 +12,11 @@ def create_default_collections():
c = Collection.objects.all().delete()
root, created = Collection.objects.get_or_create(name='Root', depth=0)
versicherungsvermittler = root.add_child(name='Versicherungsvermittler/in')
handlungsfelder = versicherungsvermittler.add_child(name='Handlungsfelder')
handlungsfelder_names = ['Fahrzeug', 'Reisen', 'Einkommensicherung', 'Gesundheit', 'Haushalt', 'Sparen',
'Pensionierung', 'KMU', 'Wohneigentum', 'Rechtsstreitigkeiten', 'Erben / Vererben',
'Selbständigkeit']
for handlungsfeld in handlungsfelder_names:
versicherungsvermittler = handlungsfelder.add_child(name=handlungsfeld)
for course in Course.objects.all():
course_collection = root.add_child(name=course.name)
for cat in course.coursecategory_set.all():
cat_collection = course_collection.add_child(name=cat.name)
def create_default_documents():
@ -33,7 +30,7 @@ def create_default_documents():
document = LibraryDocumentFactory(
title='V1 C25 ZGB CH',
display_text='Schweizerisches Zivilgesetzbuch',
description='Ein wundervolles Dokument, Bachblüten für Leseratten und metaphysisches Wolbefinden für Handyvekäufer.',
description='Ein wundervolles Dokument, Bachblüten für Leseratten und metaphysisches Wohlbefinden für Handyvekäufer.',
link_display_text='Dokument laden',
file=factory.django.FileField(from_path=os.path.join(path, filename), filename=filename),
collection=collection
@ -43,10 +40,8 @@ def create_default_documents():
document = LibraryDocumentFactory(
title='V1 C25 ',
display_text='Pdf showcase ',
description='Ein wundervolles Dokument, Bachblüten für Leseratten und metaphysisches Wolbefinden für Handyvekäufer.',
description='Ein wundervolles Dokument, Bachblüten für Leseratten und metaphysisches Wohlbefinden für Handyvekäufer.',
link_display_text='Dokument laden',
file=factory.django.FileField(from_path=os.path.join(path, filename), filename=filename),
collection=collection
)
pass

View File

@ -0,0 +1,41 @@
import json
from vbv_lernwelt.course.consts import COURSE_VERSICHERUNGSVERMITTLERIN
from vbv_lernwelt.course.models import CoursePage, Course
from vbv_lernwelt.media_library.tests.media_library_factories import MediaLibraryPageFactory, MediaCategoryPageFactory, \
create_media_content_link, LinkBlockFactory, create_link_collection, create_document_collection
def create_default_media_library():
course = Course.objects.get(id=COURSE_VERSICHERUNGSVERMITTLERIN)
course_page = CoursePage.objects.get(course_id=COURSE_VERSICHERUNGSVERMITTLERIN)
media_lib_page = MediaLibraryPageFactory(
title='Mediathek',
parent=course_page,
)
for cat in course.coursecategory_set.all():
introduction_text = '''
Das Auto ist für viele der grösste Stolz! Es birgt aber auch ein grosses Gefahrenpotenzial.
Dabei geht es bei den heutigen Fahrzeugpreisen und Reparaturkosten rasch um namhafte Summen,
die der Fahrzeugbesitzer und die Fahrzeugbesitzerin in einem grösseren Schadenfall oft nur schwer selbst aufbringen kann.
'''.strip()
description = 'Supi'
body_data = json.dumps([
create_document_collection(),
create_link_collection(
links_dict=[
create_media_content_link(LinkBlockFactory(title='Nationales Versicherungsbüro', url='https://www.vbv.ch/')),
create_media_content_link(LinkBlockFactory(title='Adressen der Strassenverkehrsämter', url='https://www.vbv.ch/')),
]
)
])
media_category = MediaCategoryPageFactory(
title=cat.name,
course_category=cat,
parent=media_lib_page,
introduction_text=introduction_text,
description=description,
body=body_data,
)

View File

@ -1,10 +1,11 @@
import djclick as click
from vbv_lernwelt.media_library.create_default_documents import create_default_collections, create_default_documents
from vbv_lernwelt.media_library.create_default_media_library import create_default_media_library
@click.command()
def command():
create_default_collections()
create_default_documents()
create_default_media_library()

View File

@ -1,9 +1,13 @@
# Generated by Django 3.2.13 on 2022-08-16 08:35
# Generated by Django 3.2.13 on 2022-09-23 12:42
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import taggit.managers
import vbv_lernwelt.media_library.content_blocks
import wagtail.blocks
import wagtail.documents.blocks
import wagtail.fields
import wagtail.models.collections
import wagtail.search.index
@ -13,14 +17,48 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
('wagtailcore', '0069_log_entry_jsonfield'),
('course', '0001_initial'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('taggit', '0004_alter_taggeditem_content_type_alter_taggeditem_tag'),
('wagtailcore', '0069_log_entry_jsonfield'),
]
operations = [
migrations.CreateModel(
name='CustomDocument',
name='MediaLibraryContent',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.TextField()),
('description', models.TextField()),
('link_display_text', models.CharField(max_length=255)),
],
),
migrations.CreateModel(
name='MediaLibraryPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='MediaCategoryPage',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
('introduction_text', models.TextField(default='')),
('description', wagtail.fields.RichTextField(default='')),
('body', wagtail.fields.StreamField([('content_collection', wagtail.blocks.StructBlock([('title', wagtail.blocks.TextBlock()), ('contents', wagtail.blocks.StreamBlock([('Links', wagtail.blocks.StructBlock([('title', wagtail.blocks.TextBlock(blank=False, null=False)), ('description', wagtail.blocks.TextBlock(default='')), ('link_display_text', wagtail.blocks.CharBlock(default='Link öffnen', max_length=255)), ('url', wagtail.blocks.URLBlock())])), ('Documents', wagtail.documents.blocks.DocumentChooserBlock()), ('Ankers', vbv_lernwelt.media_library.content_blocks.AnchorBlock()), ('CrossReference', wagtail.blocks.StructBlock([('description', wagtail.blocks.TextBlock(default='')), ('link_display_text', wagtail.blocks.CharBlock(default='Link öffnen', max_length=255)), ('category', wagtail.blocks.PageChooserBlock(page_type=['media_library.MediaCategoryPage']))]))]))]))], null=True, use_json_field=True)),
('course_category', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.coursecategory')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='LibraryDocument',
fields=[
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('title', models.CharField(max_length=255, verbose_name='title')),

View File

@ -1,42 +0,0 @@
# Generated by Django 3.2.13 on 2022-08-18 12:14
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('wagtailcore', '0069_log_entry_jsonfield'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('taggit', '0004_alter_taggeditem_content_type_alter_taggeditem_tag'),
('media_library', '0001_initial'),
]
operations = [
migrations.CreateModel(
name='Category',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page',),
),
migrations.CreateModel(
name='TopCategory',
fields=[
('page_ptr', models.OneToOneField(auto_created=True, on_delete=django.db.models.deletion.CASCADE, parent_link=True, primary_key=True, serialize=False, to='wagtailcore.page')),
],
options={
'abstract': False,
},
bases=('wagtailcore.page',),
),
migrations.RenameModel(
old_name='CustomDocument',
new_name='LibraryDocument',
),
]

View File

@ -1,8 +1,74 @@
from django.db import models
# Create your models here.
from wagtail.models import Page
from django.utils.text import slugify
from wagtail import fields
from wagtail.admin.panels import FieldPanel, StreamFieldPanel
from wagtail.documents.models import AbstractDocument, Document
from wagtail.models import Page
from vbv_lernwelt.core.model_utils import find_available_slug
from vbv_lernwelt.learnpath.serializer_helpers import get_it_serializer_class
from vbv_lernwelt.media_library.content_blocks import MediaContentCollection
class MediaLibraryPage(Page):
parent_page_types = ['course.CoursePage']
subpage_types = ['media_library.MediaCategoryPage']
content_panels = [
FieldPanel('title', classname="full title"),
]
def full_clean(self, *args, **kwargs):
self.slug = find_available_slug(slugify(f"{self.get_parent().slug}-media", allow_unicode=True))
super(MediaLibraryPage, self).full_clean(*args, **kwargs)
@classmethod
def get_serializer_class(cls):
return get_it_serializer_class(
cls, [
'id', 'title', 'slug', 'type', 'translation_key',
'course',
'children',
]
)
class MediaCategoryPage(Page):
"""
Handlungsfeld. zB. Fahrzeug
"""
course_category = models.ForeignKey('course.CourseCategory', on_delete=models.CASCADE)
parent_page_types = ['media_library.MediaLibraryPage']
introduction_text = models.TextField(default='')
description = fields.RichTextField(default='')
body = fields.StreamField(
[('content_collection', MediaContentCollection())],
use_json_field=True,
null=True
)
content_panels = [
FieldPanel('title', classname="full title"),
FieldPanel('course_category'),
FieldPanel('introduction_text', classname="introduction text"),
FieldPanel('description', classname="introduction text"),
StreamFieldPanel('body')
]
def full_clean(self, *args, **kwargs):
self.slug = find_available_slug(slugify(f"{self.get_parent()}-cat-{self.title}", allow_unicode=True))
super(MediaCategoryPage, self).full_clean(*args, **kwargs)
@classmethod
def get_serializer_class(cls):
return get_it_serializer_class(
cls, field_names=[
'id', 'title', 'slug', 'type', 'translation_key',
'course_category',
'introduction_text', 'description', 'body',
]
)
class LibraryDocument(AbstractDocument):
@ -14,58 +80,6 @@ class LibraryDocument(AbstractDocument):
link_display_text = models.CharField(max_length=1024, default='')
thumbnail = models.URLField()
admin_form_fields = Document.admin_form_fields + (
'display_text', 'description', 'link_display_text', 'thumbnail'
)
class TopCategory(Page):
"""
Handlungsfelder
"""
parent_page_types = ['learnpath.LearningPath']
subpage_types = ['media_library.Category']
# Todo: use wagtail collections for this...
class Category(Page):
"""
Handlungsfeld
"""
parent_page_types = ['media_library.TopCategory']
#
# description
# thumbnail_image
# description_image
# additional_content # Rich text field
# documents = []
#
#
# class LibraryDocument(CustomDocument):
# """
# Extension from the standart Wagtail document.
# """
# pass
#
#
# class LibraryLink():
# """
# Custom Link Block
#
# """
# pass
#
#
# class LearningPathReference():
# icon
# pass
#
#
# class CrossReference():
# pass

View File

@ -1,9 +1,10 @@
import uuid
import wagtail_factories
from vbv_lernwelt.learnpath.models import LearningPath, Topic, Circle, LearningSequence, LearningContent, LearningUnit, \
LearningUnitQuestion
from vbv_lernwelt.media_library.models import LibraryDocument
from vbv_lernwelt.media_library.content_blocks import MediaContentCollection, AnchorBlock, LinkBlock, \
CrossReferenceBlock
from vbv_lernwelt.media_library.models import LibraryDocument, MediaLibraryPage, MediaCategoryPage
class LibraryDocumentFactory(wagtail_factories.DocumentFactory):
@ -13,3 +14,87 @@ class LibraryDocumentFactory(wagtail_factories.DocumentFactory):
class Meta:
model = LibraryDocument
class MediaLibraryPageFactory(wagtail_factories.PageFactory):
title = 'Mediathek'
class Meta:
model = MediaLibraryPage
class AnchorBlockFactory(wagtail_factories.StructBlockFactory):
class Meta:
model = AnchorBlock
class LinkBlockFactory(wagtail_factories.StructBlockFactory):
title = 'Interesting link'
description = 'This link is really interesting...'
url = 'https://www.vbv.ch/'
class Meta:
model = LinkBlock
class CrossReferenceBlockFactory(wagtail_factories.StructBlockFactory):
class Meta:
model = CrossReferenceBlock
class MediaContentCollectionFactory(wagtail_factories.StructBlockFactory):
title = 'Links'
contents = wagtail_factories.StreamFieldFactory({
'Links': LinkBlockFactory,
'Documents': LibraryDocumentFactory
})
class Meta:
model = MediaContentCollection
class MediaCategoryPageFactory(wagtail_factories.PageFactory):
title = 'Fahrzeug'
introduction_text = 'Das Auto ist für viele der grösste Stolz! Es birgt aber ...'
description = 'Das erwartet dich in diesem Handlungsfeld'
class Meta:
model = MediaCategoryPage
def create_media_content_link(link_block=None):
if link_block is None:
link_block = LinkBlockFactory()
return {
"id": str(uuid.uuid4()),
"type": "Links",
"value": dict(link_block.items())
}
def create_link_collection(links_dict=None):
return {
"id": str(uuid.uuid4()),
"type": "content_collection",
"value": {
"title": "Links",
"contents": [link_dict for link_dict in links_dict]
}
}
def create_document_collection(document_ids=None):
if document_ids is None:
document_ids = [d.id for d in LibraryDocument.objects.all()]
return {
"id": str(uuid.uuid4()),
"type": "content_collection",
"value": {
"title": "Lernmedien",
"contents": [{
"id": str(uuid.uuid4()),
"type": "Documents",
"value": doc_id
} for doc_id in document_ids]
}
}

View File

@ -0,0 +1,29 @@
from django.test import TestCase
from vbv_lernwelt.core.create_default_users import create_default_users
from vbv_lernwelt.core.tests.helpers import create_locales_for_wagtail
from vbv_lernwelt.learnpath.tests.learning_path_factories import LearningPathFactory
from vbv_lernwelt.media_library.create_default_media_library import create_default_media_library
from vbv_lernwelt.media_library.models import MediaLibraryPage, MediaCategoryPage
class TestCreateDefaultDocuments(TestCase):
def setUp(self) -> None:
create_default_users()
create_locales_for_wagtail()
LearningPathFactory()
create_default_media_library()
def test_create_default_media_library(self):
self.assertEqual(MediaLibraryPage.objects.all().count(), 1)
self.assertEqual(MediaCategoryPage.objects.all().count(), 12)
def test_create_category_fahrzeug_contains_content(self):
fahrzeug = MediaCategoryPage.objects.get(title='Fahrzeug')

View File

@ -0,0 +1,47 @@
import json
from django.test import TestCase
from vbv_lernwelt.core.create_default_users import create_default_users
from vbv_lernwelt.core.tests.helpers import create_locales_for_wagtail
from vbv_lernwelt.media_library.models import MediaCategoryPage
from vbv_lernwelt.media_library.tests.media_library_factories import MediaContentCollectionFactory, MediaCategoryPageFactory, \
LinkBlockFactory, generate_default_content2, collection_body_dict
class TestMediaLibraryFactories(TestCase):
def setUp(self) -> None:
create_default_users()
create_locales_for_wagtail()
def test_content_collection_factory(self):
content_collection = MediaContentCollectionFactory()
self.assertEqual(content_collection.get('title'), 'Links')
self.assertEqual(content_collection.get('collection_type'), 'LearningMedia')
def test_link_block_factory(self):
link = LinkBlockFactory(title='MyLink')
self.assertEqual(link.get('description'), 'This link is really interesting...')
self.assertEqual(link.get('url'), 'www.example.com')
self.assertEqual(link.get('link_display_text'), 'Link öffnen')
self.assertEqual(link.get('title'), 'MyLink')
def test_category_contains_content_collection(self):
default_content = generate_default_content2()
default_content['body__content_collection__0__title'] = 'Spidf'
category = MediaCategoryPageFactory(**default_content)
print(category.body.raw_data)
self.assertNotEqual(category.body.raw_data, [])
def collection_via_dict_generation(self):
category = MediaCategoryPageFactory()
category.body = json.dumps(collection_body_dict())
category.save()
category_id = category.id
new_category = MediaCategoryPage.objects.get(id=category_id)
self.assertNotEqual(new_category.body, [])
self.assertNotEqual(new_category.body, [])

View File

@ -1,3 +0,0 @@
from django.shortcuts import render
# Create your views here.