diff --git a/client/src/components/circle/LearningSequence.vue b/client/src/components/circle/LearningSequence.vue index a809023e..c9f0f43d 100644 --- a/client/src/components/circle/LearningSequence.vue +++ b/client/src/components/circle/LearningSequence.vue @@ -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(() => {

{{ learningSequence.title }}

-
{{ learningSequence.minutes }} Minuten
+
{{ humanizeDuration(learningSequence.minutes) }}
{{ learningUnit.title }}
-
{{ learningUnit.minutes }} Minuten
+
{{ humanizeDuration(learningUnit.minutes) }}
{ + 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') +}) diff --git a/client/src/utils/humanizeDuration.ts b/client/src/utils/humanizeDuration.ts new file mode 100644 index 00000000..2a022e2c --- /dev/null +++ b/client/src/utils/humanizeDuration.ts @@ -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}` +} \ No newline at end of file diff --git a/client/src/views/CircleView.vue b/client/src/views/CircleView.vue index bd00eaae..88aed09e 100644 --- a/client/src/views/CircleView.vue +++ b/client/src/views/CircleView.vue @@ -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 '' diff --git a/client/src/views/CockpitView.vue b/client/src/views/CockpitView.vue index 66eced16..cb1bcf74 100644 --- a/client/src/views/CockpitView.vue +++ b/client/src/views/CockpitView.vue @@ -16,7 +16,7 @@ const userStore = useUserStore()

Versicherungsvermittler/in

- Weiter geht's + Weiter geht's
diff --git a/prepare_server.sh b/prepare_server.sh index 1a5b1916..3f9a1623 100755 --- a/prepare_server.sh +++ b/prepare_server.sh @@ -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" diff --git a/server/config/settings/base.py b/server/config/settings/base.py index b76eb030..047a5627 100644 --- a/server/config/settings/base.py +++ b/server/config/settings/base.py @@ -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", } diff --git a/server/config/urls.py b/server/config/urls.py index ba97a6b9..cd4ecba0 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -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//", page_api_view, name="page_api_view"), + # course + path(r"api/course/page//", page_api_view, name="page_api_view"), # completion path(r"api/completion/circle//", request_circle_completion, name="request_circle_completion"), diff --git a/server/vbv_lernwelt/completion/migrations/0001_initial.py b/server/vbv_lernwelt/completion/migrations/0001_initial.py index f6e70be8..1dccd430 100644 --- a/server/vbv_lernwelt/completion/migrations/0001_initial.py +++ b/server/vbv_lernwelt/completion/migrations/0001_initial.py @@ -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)), diff --git a/server/vbv_lernwelt/core/model_utils.py b/server/vbv_lernwelt/core/model_utils.py new file mode 100644 index 00000000..25f79086 --- /dev/null +++ b/server/vbv_lernwelt/core/model_utils.py @@ -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 diff --git a/server/vbv_lernwelt/course/__init__.py b/server/vbv_lernwelt/course/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/course/admin.py b/server/vbv_lernwelt/course/admin.py new file mode 100644 index 00000000..8c38f3f3 --- /dev/null +++ b/server/vbv_lernwelt/course/admin.py @@ -0,0 +1,3 @@ +from django.contrib import admin + +# Register your models here. diff --git a/server/vbv_lernwelt/course/apps.py b/server/vbv_lernwelt/course/apps.py new file mode 100644 index 00000000..9453e526 --- /dev/null +++ b/server/vbv_lernwelt/course/apps.py @@ -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 diff --git a/server/vbv_lernwelt/course/consts.py b/server/vbv_lernwelt/course/consts.py new file mode 100644 index 00000000..2748f762 --- /dev/null +++ b/server/vbv_lernwelt/course/consts.py @@ -0,0 +1 @@ +COURSE_VERSICHERUNGSVERMITTLERIN = -1 diff --git a/server/vbv_lernwelt/course/creators.py b/server/vbv_lernwelt/course/creators.py new file mode 100644 index 00000000..ea596192 --- /dev/null +++ b/server/vbv_lernwelt/course/creators.py @@ -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, + ) diff --git a/server/vbv_lernwelt/course/factories.py b/server/vbv_lernwelt/course/factories.py new file mode 100644 index 00000000..96eb5d7a --- /dev/null +++ b/server/vbv_lernwelt/course/factories.py @@ -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 diff --git a/server/vbv_lernwelt/course/management/__init__.py b/server/vbv_lernwelt/course/management/__init__.py new file mode 100644 index 00000000..536f0e29 --- /dev/null +++ b/server/vbv_lernwelt/course/management/__init__.py @@ -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 diff --git a/server/vbv_lernwelt/course/management/commands/__init__.py b/server/vbv_lernwelt/course/management/commands/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/course/management/commands/create_default_courses.py b/server/vbv_lernwelt/course/management/commands/create_default_courses.py new file mode 100644 index 00000000..cce91c31 --- /dev/null +++ b/server/vbv_lernwelt/course/management/commands/create_default_courses.py @@ -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() diff --git a/server/vbv_lernwelt/course/migrations/0001_initial.py b/server/vbv_lernwelt/course/migrations/0001_initial.py new file mode 100644 index 00000000..53c427f6 --- /dev/null +++ b/server/vbv_lernwelt/course/migrations/0001_initial.py @@ -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')), + ], + ), + ] diff --git a/server/vbv_lernwelt/course/migrations/__init__.py b/server/vbv_lernwelt/course/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/course/models.py b/server/vbv_lernwelt/course/models.py new file mode 100644 index 00000000..281e0ee6 --- /dev/null +++ b/server/vbv_lernwelt/course/models.py @@ -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}" diff --git a/server/vbv_lernwelt/course/serializers.py b/server/vbv_lernwelt/course/serializers.py new file mode 100644 index 00000000..b04db934 --- /dev/null +++ b/server/vbv_lernwelt/course/serializers.py @@ -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',] diff --git a/server/vbv_lernwelt/course/signals.py b/server/vbv_lernwelt/course/signals.py new file mode 100644 index 00000000..d7e767d9 --- /dev/null +++ b/server/vbv_lernwelt/course/signals.py @@ -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) diff --git a/server/vbv_lernwelt/course/views.py b/server/vbv_lernwelt/course/views.py new file mode 100644 index 00000000..cc5a4671 --- /dev/null +++ b/server/vbv_lernwelt/course/views.py @@ -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) diff --git a/server/vbv_lernwelt/learnpath/create_default_learning_path.py b/server/vbv_lernwelt/learnpath/create_default_learning_path.py index a1f27c12..0e4f6715 100644 --- a/server/vbv_lernwelt/learnpath/create_default_learning_path.py +++ b/server/vbv_lernwelt/learnpath/create_default_learning_path.py @@ -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) diff --git a/server/vbv_lernwelt/learnpath/management/commands/create_default_learning_path.py b/server/vbv_lernwelt/learnpath/management/commands/create_default_learning_path.py index 99b2b7ee..89c71c4c 100644 --- a/server/vbv_lernwelt/learnpath/management/commands/create_default_learning_path.py +++ b/server/vbv_lernwelt/learnpath/management/commands/create_default_learning_path.py @@ -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) diff --git a/server/vbv_lernwelt/learnpath/migrations/0001_initial.py b/server/vbv_lernwelt/learnpath/migrations/0001_initial.py index 04760b7b..c8769fa1 100644 --- a/server/vbv_lernwelt/learnpath/migrations/0001_initial.py +++ b/server/vbv_lernwelt/learnpath/migrations/0001_initial.py @@ -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'), - ), ] diff --git a/server/vbv_lernwelt/learnpath/migrations/0002_alter_learningcontent_contents.py b/server/vbv_lernwelt/learnpath/migrations/0002_alter_learningcontent_contents.py deleted file mode 100644 index 2e13c5ab..00000000 --- a/server/vbv_lernwelt/learnpath/migrations/0002_alter_learningcontent_contents.py +++ /dev/null @@ -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), - ), - ] diff --git a/server/vbv_lernwelt/learnpath/models.py b/server/vbv_lernwelt/learnpath/models.py index 6e6a4d13..8d46b79f 100644 --- a/server/vbv_lernwelt/learnpath/models.py +++ b/server/vbv_lernwelt/learnpath/models.py @@ -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 diff --git a/server/vbv_lernwelt/learnpath/serializer_helpers.py b/server/vbv_lernwelt/learnpath/serializer_helpers.py index 73da6ade..428135b5 100644 --- a/server/vbv_lernwelt/learnpath/serializer_helpers.py +++ b/server/vbv_lernwelt/learnpath/serializer_helpers.py @@ -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] diff --git a/server/vbv_lernwelt/learnpath/signals.py b/server/vbv_lernwelt/learnpath/signals.py deleted file mode 100644 index 48369730..00000000 --- a/server/vbv_lernwelt/learnpath/signals.py +++ /dev/null @@ -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) diff --git a/server/vbv_lernwelt/learnpath/tests/test_api.py b/server/vbv_lernwelt/learnpath/tests/test_api.py index 7fa29ae0..aa630184 100644 --- a/server/vbv_lernwelt/learnpath/tests/test_api.py +++ b/server/vbv_lernwelt/learnpath/tests/test_api.py @@ -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() diff --git a/server/vbv_lernwelt/learnpath/views.py b/server/vbv_lernwelt/learnpath/views.py index 2e6856be..e69de29b 100644 --- a/server/vbv_lernwelt/learnpath/views.py +++ b/server/vbv_lernwelt/learnpath/views.py @@ -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) - - diff --git a/server/vbv_lernwelt/media_library/content_blocks.py b/server/vbv_lernwelt/media_library/content_blocks.py new file mode 100644 index 00000000..7b39272a --- /dev/null +++ b/server/vbv_lernwelt/media_library/content_blocks.py @@ -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' diff --git a/server/vbv_lernwelt/media_library/create_default_documents.py b/server/vbv_lernwelt/media_library/create_default_documents.py index 0c03cad9..3701d1ec 100644 --- a/server/vbv_lernwelt/media_library/create_default_documents.py +++ b/server/vbv_lernwelt/media_library/create_default_documents.py @@ -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 - diff --git a/server/vbv_lernwelt/media_library/create_default_media_library.py b/server/vbv_lernwelt/media_library/create_default_media_library.py new file mode 100644 index 00000000..618b7022 --- /dev/null +++ b/server/vbv_lernwelt/media_library/create_default_media_library.py @@ -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, + ) diff --git a/server/vbv_lernwelt/media_library/management/commands/create_default_media_library.py b/server/vbv_lernwelt/media_library/management/commands/create_default_media_library.py index 93a44ca0..705d4767 100644 --- a/server/vbv_lernwelt/media_library/management/commands/create_default_media_library.py +++ b/server/vbv_lernwelt/media_library/management/commands/create_default_media_library.py @@ -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() diff --git a/server/vbv_lernwelt/media_library/migrations/0001_initial.py b/server/vbv_lernwelt/media_library/migrations/0001_initial.py index bb2696cd..b1335286 100644 --- a/server/vbv_lernwelt/media_library/migrations/0001_initial.py +++ b/server/vbv_lernwelt/media_library/migrations/0001_initial.py @@ -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')), diff --git a/server/vbv_lernwelt/media_library/migrations/0002_auto_20220818_1414.py b/server/vbv_lernwelt/media_library/migrations/0002_auto_20220818_1414.py deleted file mode 100644 index 70da2f30..00000000 --- a/server/vbv_lernwelt/media_library/migrations/0002_auto_20220818_1414.py +++ /dev/null @@ -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', - ), - ] diff --git a/server/vbv_lernwelt/media_library/models.py b/server/vbv_lernwelt/media_library/models.py index 74d3e2ee..1971429b 100644 --- a/server/vbv_lernwelt/media_library/models.py +++ b/server/vbv_lernwelt/media_library/models.py @@ -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 diff --git a/server/vbv_lernwelt/media_library/tests/media_library_factories.py b/server/vbv_lernwelt/media_library/tests/media_library_factories.py index 267a193e..4f37bfeb 100644 --- a/server/vbv_lernwelt/media_library/tests/media_library_factories.py +++ b/server/vbv_lernwelt/media_library/tests/media_library_factories.py @@ -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] + } + } diff --git a/server/vbv_lernwelt/media_library/tests/test_create_default_media_library.py b/server/vbv_lernwelt/media_library/tests/test_create_default_media_library.py new file mode 100644 index 00000000..50eb8075 --- /dev/null +++ b/server/vbv_lernwelt/media_library/tests/test_create_default_media_library.py @@ -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') + + + + + diff --git a/server/vbv_lernwelt/media_library/tests/test_media_library_factories.py b/server/vbv_lernwelt/media_library/tests/test_media_library_factories.py new file mode 100644 index 00000000..82a2198e --- /dev/null +++ b/server/vbv_lernwelt/media_library/tests/test_media_library_factories.py @@ -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, []) + + + diff --git a/server/vbv_lernwelt/media_library/views.py b/server/vbv_lernwelt/media_library/views.py index 91ea44a2..e69de29b 100644 --- a/server/vbv_lernwelt/media_library/views.py +++ b/server/vbv_lernwelt/media_library/views.py @@ -1,3 +0,0 @@ -from django.shortcuts import render - -# Create your views here.