Merged in feature/MS743-ModuleCategorization (pull request #131)

Feature/MS743 ModuleCategorization

Approved-by: Ramon Wenger
This commit is contained in:
Lorenz Padberg 2023-08-24 08:40:04 +00:00 committed by Ramon Wenger
commit 0ae86202c7
32 changed files with 1217 additions and 9399 deletions

9213
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -4,12 +4,22 @@
class="module"
v-if="module.id"
>
<div class="module__header">
<h2
class="module__meta-title"
id="meta-title"
>
{{ module.metaTitle }}
</h2>
<div class="module__categoryindicators">
<pill :text="module.level?.name"></pill>
<pill :text="module.category?.name"></pill>
</div>
</div>
<h1
class="module__title"
data-cy="module-title"
@ -85,11 +95,12 @@
</template>
<script>
import ObjectiveGroups from '@/components/objective-groups/ObjectiveGroups.vue';
import Chapter from '@/components/Chapter.vue';
import BookmarkActions from '@/components/notes/BookmarkActions.vue';
import ObjectiveGroups from '@/components/objective-groups/ObjectiveGroups.vue';
import Chapter from '@/components/Chapter.vue';
import BookmarkActions from '@/components/notes/BookmarkActions.vue';
import Pill from "@/components/ui/Pill.vue";
export default {
export default {
props: {
module: {
type: Object,
@ -98,6 +109,7 @@ export default {
},
components: {
Pill,
BookmarkActions,
ObjectiveGroups,
Chapter,
@ -126,13 +138,13 @@ export default {
return this.module.bookmark.note;
},
},
};
};
</script>
<style scoped lang="scss">
@import 'styles/helpers';
@import 'styles/helpers';
.module {
.module {
display: flex;
justify-self: center;
max-width: 100vw;
@ -161,8 +173,17 @@ export default {
line-height: 25px;
}
&__header {
display: flex;
justify-content: flex-start;
align-items: stretch;
margin-bottom: $small-spacing;
}
&__meta-title {
@include meta-title;
margin-right: $medium-spacing;
}
&__intro-wrapper {
@ -196,5 +217,5 @@ export default {
&__objective-groups {
margin-bottom: 2 * $large-spacing;
}
}
}
</style>

View File

@ -0,0 +1,199 @@
<template>
<div class="module-filter">
<div class="module-filter__filterselection">
<Dropdown
class="module-filter__dropdown"
:selected-item="selectedLevel"
:items="moduleLevels"
@update:selectedItem="updateLevel"
></Dropdown>
<Dropdown
class="module-filter__dropdown"
:selected-item="selectedCategory"
:items="moduleCategories"
@update:selectedItem="newItem => selectedCategory = newItem"
></Dropdown>
<pill-radio-buttons
:selectableItems="languageOptions"
:defaultSelectedItem="initialLanguage"
class="module-filter__language-selection"
@update:selectedItem="item => selectedLanguage = item"/>
</div>
<div class="topic__modules">
<module-teaser
v-for="module in filteredModules"
v-bind="module"
:key="module.slug"
/>
</div>
</div>
</template>
<script setup lang="ts">
import {computed, ref} from "vue";
import ModuleTeaser from "@/components/modules/ModuleTeaser.vue";
import {useQuery, useMutation} from "@vue/apollo-composable";
import Dropdown from "@/components/ui/Dropdown.vue";
import PillRadioButtons from "@/components/ui/PillRadioButtons.vue";
import gql from "graphql-tag";
const props = defineProps<{
modules: Object[];
me: any;
}>();
const languageOptions: Array<{ id: number, label: string }> = ref([
{id: 1, label: 'De'},
{id: 2, label: 'Fr'},
{id: 3, label: 'En'}
])
const initialLanguage = languageOptions.value[0]
const selectedLanguage = ref(initialLanguage)
const initialCategory = {
name: 'Alle Lernfelder',
id: null,
filterAttributeType: 'ALL'
};
const initialLevel = {
name: 'Alle Lehrjahre',
id: null,
filterAttributeType: 'ALL'
};
const {result: moduleFilterResult} = useQuery(gql`
query ModuleFilterQuery{
moduleLevels {
name
id
filterAttributeType
}
moduleCategories {
name
id
filterAttributeType
}
}
`);
const selectedLevel = ref(initialLevel);
const selectedCategory = ref(initialCategory);
selectedLevel.value = loadDefaultLevel(props.me);
const moduleLevels = computed(() => moduleFilterResult.value?.moduleLevels || []);
const moduleCategories = computed(() => moduleFilterResult.value?.moduleCategories || []);
const filteredModules = computed(() => props.modules?.filter(module => filterModule(module,
selectedLevel.value, selectedCategory.value, selectedLanguage.value)) || []);
function loadDefaultLevel(me) {
return me?.lastModuleLevel || initialLevel;
}
function filterModule(module: Object, selectedLevel, selectedCategory, selectedLanguage) {
let filterExpression = true;
filterExpression = filterByLevel(module, selectedLevel) &&
filterByCategory(module, selectedCategory) &&
filterByLanguage(module, selectedLanguage);
return filterExpression;
}
const updateLevel = (level) => {
selectedLevel.value = level;
updateLastModuleLevelUser(level);
}
const filterByLevel = (module, level) => {
return level.filterAttributeType === 'ALL' || module.level?.id === level.id
}
const filterByCategory = (module, category) => {
return category.filterAttributeType === 'ALL' || module.category?.id === category.id
}
const filterByLanguage = (module: Object, language: Object) => {
// TODO: implement filter by language here.
console.log("selectedLanguage", selectedLanguage.value, language);
console.log("module.languages", module);
return true
}
const {mutate: updateLastModuleLevel} = useMutation(gql `
mutation ($input: UpdateLastModuleLevelInput!){
updateLastModuleLevel(input: $input) {
clientMutationId
user {
username
lastModuleLevel {
name
id
}
}
}
}`);
const updateLastModuleLevelUser = (moduleLevel: Object) => {
updateLastModuleLevel({
input: {
id: moduleLevel.id,
},
});
}
</script>
<style scoped lang="scss">
@import 'styles/helpers';
.module-filter {
// TODO: how do I correcty set the with of the whole thig including the grid for the modules?
// TODO: Farbe des Arrows für Dropdowns muss platfrom habhängig sein MS-775
width: 75%;
&__filterselection {
display: flex;
flex-wrap: wrap;
justify-content: space-between;
}
&__dropdown {
margin-right: $medium-spacing;
width: 300px;
}
&__language-selection {
margin-left: auto;
}
}
.topic {
&__modules {
margin-top: 40px;
display: flex;
flex-wrap: wrap;
@supports (display: grid) {
display: grid;
}
grid-column-gap: $large-spacing;
grid-row-gap: $large-spacing;
@include desktop {
grid-template-columns: repeat(3, minmax(auto, 380px));
}
}
}
</style>

View File

@ -17,13 +17,23 @@
<p class="module-teaser__description">
{{ teaser }}
</p>
<div class="module-teaser__pills">
<pill :text="level?.name"></pill>
<pill :text="category?.name"></pill>
</div>
</div>
</router-link>
</template>
<script>
export default {
props: ['metaTitle', 'title', 'teaser', 'id', 'slug', 'heroImage'],
import Pill from "@/components/ui/Pill.vue";
export default {
props: ['metaTitle', 'title', 'teaser', 'id', 'slug', 'heroImage', 'level', 'category'],
components: {Pill},
computed: {
moduleLink() {
@ -39,16 +49,16 @@ export default {
}
},
},
};
};
</script>
<style scoped lang="scss">
@import 'styles/helpers';
@import 'styles/helpers';
.module-teaser {
.module-teaser {
box-shadow: 0 3px 9px 0 rgba(0, 0, 0, 0.12);
border: 1px solid #e2e2e2;
height: 330px;
height: 390px;
max-width: 380px;
width: 100%;
border-radius: 12px;
@ -88,5 +98,9 @@ export default {
line-height: $default-line-height;
font-size: 1.2rem;
}
}
&__pills {
margin-top: $medium-spacing;
}
}
</style>

View File

@ -0,0 +1,128 @@
<template>
<div
class="dropdown"
v-if="selectedItem"
>
<div
data-cy="dropdown"
class="dropdown__selected-item"
@click.stop="toggle()"
>
<span class="dropdown__selected-item-text"> {{ selectedItem.name }}</span>
<ChevronDown class="dropdown__dropdown-icon" :class="{'rotate-chevron': showPopover}"
/>
</div>
<transition name="scaleY">
<WidgetPopover
class="dropdown__popover"
v-if="showPopover"
@hide-me="showPopover = false"
>
<li
:label="selectedItem.name"
:item="selectedItem"
data-cy="dropdown-item"
class="popover-links__link popover-links__link--large"
v-for="item in items"
:key="item.id"
@click="updateSelectedItem(item)"
>{{ item.name }}
</li>
</WidgetPopover>
</transition>
</div>
</template>
<script setup lang="ts">
import WidgetPopover from '@/components/ui/WidgetPopover.vue';
import {ref} from "vue";
import ChevronDown from "@/components/icons/ChevronDown.vue";
defineProps<{
selectedItem: Object,
items: Object[],
}>();
let showPopover = ref(false)
function toggle() {
showPopover.value = !showPopover.value;
}
function updateSelectedItem(item: Object) {
emit('update:selectedItem', item);
showPopover.value = false;
}
const emit = defineEmits(['update:selectedItem']);
</script>
<style scoped lang="scss">
@import 'styles/helpers';
.dropdown {
position: relative;
cursor: pointer;
margin-bottom: $medium-spacing;
border-radius: $input-border-radius;
&__popover {
white-space: nowrap;
top: 100%;
left: 0;
transform-origin: top;
}
&__selected-item {
width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
border: solid $color-silver 1px;
border-radius: $input-border-radius;
padding: 12px;
}
&__selected-item-text {
@include small-text;
margin-right: $small-spacing;
}
&__dropdown-icon {
width: $default-icon-dimension;
height: $default-icon-dimension;
transition: transform 0.3s ease;
fill: $color-brand;
&.rotate-chevron {
transform: rotate(180deg);
}
}
&__popover {
width: 100%;
border-radius: $default-border-radius;
margin-top: 4px;
.popover-links__link {
&:hover {
background-color: $color-silver-light;
}
}
}
}
.scaleY-enter-active,
.scaleY-leave-active {
transition: transform 0.3s;
}
.scaleY-enter,
.scaleY-leave-to {
transform: scaleY(0);
}
</style>

View File

@ -0,0 +1,26 @@
<template>
<div class="pill" v-if="props.text">{{ props.text }}</div>
</template>
<script setup lang="ts">
const props = defineProps<{
text: String;
}>();
</script>
<style scoped lang="scss">
@import 'styles/helpers';
.pill {
background-color: white;
color: $color-charcoal-dark; /* Replace with your desired text color */
padding: $small-spacing $medium-spacing;
border-radius: $round-border-radius;
border: 1px solid $color-silver;
display: inline-block; /* Ensures the pill takes only the necessary width */
margin-right: $small-spacing;
margin-top: 2px;
@include small-text;
}
</style>

View File

@ -0,0 +1,69 @@
<template>
<div class="pill-radio">
<button
:class="['pill-radio__button', { 'pill-radio__button--active': item === selectedItem }]"
v-for="item in props.selectableItems"
:key="item.id"
@click="updateSelectedItem(item)"
>
{{ item.label }}
</button>
</div>
</template>
<script setup lang="ts">
import {ref} from 'vue';
const emit = defineEmits(['update:selectedItem']);
const props = defineProps<{
selectableItems: Object;
defaultSelectedItem: Object;
}>();
const selectedItem = ref(props.defaultSelectedItem)
function updateSelectedItem(item: object) {
emit('update:selectedItem', item);
selectedItem.value = item
}
</script>
<style scoped lang="scss">
@import 'styles/helpers';
.pill-radio {
display: flex;
overflow: hidden;
height: 40px;
&__button {
flex: 1;
cursor: pointer;
background-color: $color-silver-light;
outline: none;
padding-left: 1rem;
padding-right: 1rem;
border: none;
&--active {
background-color: white;
border: solid $color-silver 1px;
}
}
&__button:first-child {
border-top-left-radius: $round-border-radius;
border-bottom-left-radius: $round-border-radius;
}
&__button:last-child {
border-top-right-radius: $round-border-radius;
border-bottom-right-radius: $round-border-radius;
}
}
</style>

View File

@ -9,6 +9,14 @@ fragment ModuleParts on ModuleNode {
heroSource
solutionsEnabled
inEditMode @client
level {
id
name
}
category {
id
name
}
topic {
slug
title

View File

@ -10,6 +10,11 @@ fragment UserParts on PrivateUserNode {
avatarUrl
expiryDate
readOnly
lastModuleLevel {
id
name
filterAttributeType
}
lastModule {
id
slug

View File

@ -14,6 +14,7 @@ export interface Me {
team: any;
lastTopic: any;
readOnly: boolean;
lastModuleLevel: any;
}
export interface MeQuery {
@ -30,6 +31,7 @@ export interface Location {
type RouteLocation = Location | string;
// TODO: ME_QUERY existiert an einem weiteren Ort. Dieser sollte entfernt werden.
const defaultMe: MeQuery = {
me: {
selectedClass: {
@ -42,6 +44,7 @@ const defaultMe: MeQuery = {
team: null,
readOnly: false,
lastTopic: undefined,
lastModuleLevel: undefined,
},
};

View File

@ -14,6 +14,7 @@
<p class="topic__teaser">
{{ topic.teaser }}
</p>
<div class="topic__links">
<div
class="topic__video-link topic__link"
@ -33,19 +34,15 @@
<span class="topic__link-description">Anweisungen zum {{ $flavor.textTopic }} anzeigen</span>
</a>
</div>
<div class="topic__modules">
<module-teaser
v-for="module in modules"
v-bind="module"
:key="module.slug"
/>
<div class="topic__modulefilter">
<module-filter :modules="modules" :me="me" v-if="modules.length > 0" ></module-filter>
</div>
</div>
</div>
</template>
<script>
import ModuleTeaser from '@/components/modules/ModuleTeaser.vue';
import ModuleFilter from '@/components/modules/ModuleFilter.vue';
import { defineAsyncComponent } from 'vue';
import TOPIC_QUERY from '@/graphql/gql/queries/topicQuery.gql';
import me from '@/mixins/me';
@ -61,9 +58,9 @@ export default {
mixins: [me],
components: {
TopicNavigation,
ModuleTeaser,
PlayIcon,
BulbIcon,
ModuleFilter,
},
apollo: {
@ -170,6 +167,7 @@ export default {
grid-template-columns: 300px 1fr;
}
&__navigation {
padding: 0 $medium-spacing;
display: none;
@ -210,19 +208,5 @@ export default {
@include heading-3;
}
&__modules {
margin-top: 40px;
display: flex;
flex-wrap: wrap;
@supports (display: grid) {
display: grid;
}
grid-column-gap: $large-spacing;
grid-row-gap: $large-spacing;
@include desktop {
grid-template-columns: repeat(3, minmax(auto, 380px));
}
}
}
</style>

97
package-lock.json generated
View File

@ -1,101 +1,16 @@
{
"name": "cariot",
"version": "1.0.1",
"name": "myskillbox",
"version": "1.1.0",
"lockfileVersion": 2,
"requires": true,
"packages": {
"": {
"name": "cariot",
"version": "1.0.1",
"dependencies": {
"babel-polyfill": "^6.26.0",
"unfetch": "^3.0.0"
},
"name": "myskillbox",
"version": "1.1.0",
"engines": {
"node": "8.x"
"node": "20.x",
"npm": ">= 8.x"
}
},
"node_modules/babel-polyfill": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-polyfill/-/babel-polyfill-6.26.0.tgz",
"integrity": "sha1-N5k3q8Z9eJWXCtxiHyhM2WbPIVM=",
"dependencies": {
"babel-runtime": "^6.26.0",
"core-js": "^2.5.0",
"regenerator-runtime": "^0.10.5"
}
},
"node_modules/babel-runtime": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
"integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=",
"dependencies": {
"core-js": "^2.4.0",
"regenerator-runtime": "^0.11.0"
}
},
"node_modules/babel-runtime/node_modules/regenerator-runtime": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="
},
"node_modules/core-js": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.7.tgz",
"integrity": "sha512-RszJCAxg/PP6uzXVXL6BsxSXx/B05oJAQ2vkJRjyjrEcNVycaqOmNb5OTxZPE3xa5gwZduqza6L9JOCenh/Ecw=="
},
"node_modules/regenerator-runtime": {
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz",
"integrity": "sha1-M2w+/BIgrc7dosn6tntaeVWjNlg="
},
"node_modules/unfetch": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/unfetch/-/unfetch-3.1.1.tgz",
"integrity": "sha512-syDl3htvM56w0HC0PTVA5jEEknOCJ3dWgWGDuaEtQUno8ORDCfZQbm12RzfWO3AC3YhWDoP61dlgmo8Z05Y97g=="
}
},
"dependencies": {
"babel-polyfill": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-polyfill/-/babel-polyfill-6.26.0.tgz",
"integrity": "sha1-N5k3q8Z9eJWXCtxiHyhM2WbPIVM=",
"requires": {
"babel-runtime": "^6.26.0",
"core-js": "^2.5.0",
"regenerator-runtime": "^0.10.5"
}
},
"babel-runtime": {
"version": "6.26.0",
"resolved": "https://registry.npmjs.org/babel-runtime/-/babel-runtime-6.26.0.tgz",
"integrity": "sha1-llxwWGaOgrVde/4E/yM3vItWR/4=",
"requires": {
"core-js": "^2.4.0",
"regenerator-runtime": "^0.11.0"
},
"dependencies": {
"regenerator-runtime": {
"version": "0.11.1",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz",
"integrity": "sha512-MguG95oij0fC3QV3URf4V2SDYGJhJnJGqvIIgdECeODCT98wSWDAJ94SSuVpYQUoTcGUIL6L4yNB7j1DFFHSBg=="
}
}
},
"core-js": {
"version": "2.5.7",
"resolved": "https://registry.npmjs.org/core-js/-/core-js-2.5.7.tgz",
"integrity": "sha512-RszJCAxg/PP6uzXVXL6BsxSXx/B05oJAQ2vkJRjyjrEcNVycaqOmNb5OTxZPE3xa5gwZduqza6L9JOCenh/Ecw=="
},
"regenerator-runtime": {
"version": "0.10.5",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.10.5.tgz",
"integrity": "sha1-M2w+/BIgrc7dosn6tntaeVWjNlg="
},
"unfetch": {
"version": "3.1.1",
"resolved": "https://registry.npmjs.org/unfetch/-/unfetch-3.1.1.tgz",
"integrity": "sha512-syDl3htvM56w0HC0PTVA5jEEknOCJ3dWgWGDuaEtQUno8ORDCfZQbm12RzfWO3AC3YhWDoP61dlgmo8Z05Y97g=="
}
}
}

View File

@ -0,0 +1,60 @@
from users.models import User
from .models.module import Module, ModuleLevel, ModuleCategory
def analyze_module_meta_titles():
all_nodes = []
for module in Module.objects.all():
print(f"{module.get_parent() } {module.meta_title} : {module.title}")
nodes = [i.strip() for i in [module.get_parent().title] + module.meta_title.split(' ')]
all_nodes.append(nodes)
print("")
for i in range(3):
leafs = [node[i] for node in all_nodes if len(node) > i+1]
leafs.sort()
print(f"{i}.: {set(leafs)}")
def create_default_levels_and_categories():
ModuleLevel.objects.get_or_create(name=f"Alle Lehrjahre", filter_attribute_type="all")
for lehrjahr in range(1,4):
module_category, created = ModuleLevel.objects.get_or_create(name=f"{lehrjahr}. Lehrjahr")
ModuleCategory.objects.get_or_create(name=f"Alle Lernfelder", filter_attribute_type="all")
for category in range(1, 10):
ModuleCategory.objects.get_or_create(name=f"Lernfeld {category}")
def categorize_modules():
for level in ModuleLevel.objects.all():
modules = Module.objects.filter(level__isnull=True, meta_title__icontains=level.name)
print(f"{level.name}: {modules.count()}")
modules.update(level=level)
for category in ModuleCategory.objects.all():
modules = Module.objects.filter(category__isnull=True, meta_title__contains=category.name)
print(f"{category.name}: {modules.count()}")
modules.update(category=category)
def uncategorize_modules():
User.objects.all().update(last_module_level=None)
#
ModuleCategory.objects.all().delete()
ModuleLevel.objects.all().delete()
def delete_unused_levels():
for level in ModuleLevel.objects.filter(filter_attribute_type="exact"):
if not level.module_set.exists():
level.delete()
def delete_unused_categories():
for category in ModuleCategory.objects.filter(filter_attribute_type="exact"):
if not category.module_set.exists():
category.delete()

View File

@ -28,7 +28,7 @@ from books.blocks import (
SurveyBlock,
VideoBlock,
)
from books.models import Book, Chapter, ContentBlock, Module, TextBlock, Topic
from books.models import Book, Chapter, ContentBlock, Module, TextBlock, Topic, ModuleLevel, ModuleCategory
from core.factories import (
BasePageFactory,
DummyImageFactory,
@ -201,6 +201,17 @@ class VideoBlockFactory(wagtail_factories.StructBlockFactory):
class Meta:
model = VideoBlock
class ModuleLevelFactory(factory.DjangoModelFactory):
class Meta:
model = ModuleLevel
name = '1. Lehrjahr'
class ModuleTypeFactory(factory.DjangoModelFactory):
class Meta:
model = ModuleCategory
name = 'Lernfeld 1'
block_types = [
"text_block",

View File

@ -0,0 +1,18 @@
from django.core.management import BaseCommand
from books.categorize_modules import categorize_modules, delete_unused_levels, uncategorize_modules, \
delete_unused_categories
from books.categorize_modules import create_default_levels_and_categories
class Command(BaseCommand):
def handle(self, *args, **options):
self.stdout.write("Categorizing modules")
uncategorize_modules()
create_default_levels_and_categories()
categorize_modules()
delete_unused_levels()
delete_unused_categories()
self.stdout.write("Finish categorizing modules")

View File

@ -0,0 +1,11 @@
from django.core.management import BaseCommand
from books.categorize_modules import categorize_modules, create_default_levels_and_categories
class Command(BaseCommand):
def handle(self, *args, **options):
self.stdout.write("Create defaut categories")
create_default_levels_and_categories()
self.stdout.write("Finish")

View File

@ -0,0 +1,49 @@
# Generated by Django 3.2.16 on 2023-08-22 12:19
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('books', '0042_alter_contentblock_contents'),
]
operations = [
migrations.CreateModel(
name='ModuleCategory',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255)),
('filter_attribute_type', models.CharField(choices=[('all', 'All'), ('exact', 'Exact')], default='exact', max_length=16)),
],
options={
'verbose_name': 'module type',
'verbose_name_plural': 'module types',
'ordering': ('name',),
},
),
migrations.CreateModel(
name='ModuleLevel',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=255, unique=True)),
('filter_attribute_type', models.CharField(choices=[('all', 'All'), ('exact', 'Exact')], default='exact', max_length=16)),
],
options={
'verbose_name': 'module level',
'verbose_name_plural': 'module Levels',
},
),
migrations.AddField(
model_name='module',
name='category',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='books.modulecategory'),
),
migrations.AddField(
model_name='module',
name='level',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='books.modulelevel'),
),
]

View File

@ -1,13 +1,46 @@
from django.db import models
from django.utils import timezone
from wagtail.admin.panels import FieldPanel, InlinePanel, TabbedInterface, ObjectList
from wagtail.fields import RichTextField
from django.utils.translation import gettext_lazy as _
from wagtail.admin.panels import FieldPanel, TabbedInterface, ObjectList
from wagtail.fields import RichTextField
from core.constants import DEFAULT_RICH_TEXT_FEATURES
from core.wagtail_utils import StrictHierarchyPage, get_default_settings
from users.models import SchoolClass
EXACT = "exact"
FILTER_ATTRIBUTE_TYPE = (("all", "All"), (EXACT, "Exact"))
class ModuleLevel(models.Model):
name = models.CharField(max_length=255, unique=True)
filter_attribute_type = models.CharField(
max_length=16, choices=FILTER_ATTRIBUTE_TYPE, default=EXACT
)
def __str__(self):
return self.name
class Meta:
verbose_name_plural = _("module Levels")
verbose_name = _("module level")
class ModuleCategory(models.Model):
class Meta:
verbose_name = _("module type")
verbose_name_plural = _("module types")
ordering = ("name",)
name = models.CharField(max_length=255)
filter_attribute_type = models.CharField(
max_length=16, choices=FILTER_ATTRIBUTE_TYPE, default=EXACT
)
def __str__(self):
return f"{self.name}"
class Module(StrictHierarchyPage):
class Meta:
@ -15,6 +48,13 @@ class Module(StrictHierarchyPage):
verbose_name_plural = "Module"
meta_title = models.CharField(max_length=255, help_text="e.g. 'Intro' or 'Modul 1'")
level = models.ForeignKey(
ModuleLevel, on_delete=models.SET_NULL, blank=True, null=True
)
category = models.ForeignKey(
ModuleCategory, on_delete=models.SET_NULL, blank=True, null=True
)
hero_image = models.ForeignKey(
"wagtailimages.Image",
null=True,
@ -34,30 +74,12 @@ class Module(StrictHierarchyPage):
content_panels = [
FieldPanel("title", classname="full title"),
FieldPanel("meta_title", classname="full title"),
FieldPanel("level"),
FieldPanel("category"),
FieldPanel("hero_image"),
FieldPanel("hero_source"),
FieldPanel("teaser"),
FieldPanel("intro"),
# InlinePanel(
# "assignments",
# label=_("Assignment"),
# classname="collapsed",
# heading=_("linked assignments"),
# help_text=_(
# "These %s are automatically linked, they are shown here only to provide an overview. Please don't change anything here."
# )
# % _("assignments"),
# ),
# InlinePanel(
# "surveys",
# heading=_("linked surveys"),
# label=_("Survey"),
# classname="collapsed",
# help_text=_(
# "These %s are automatically linked, they are shown here only to provide an overview. Please don't change anything here."
# )
# % _("surveys"),
# ),
]
edit_handler = TabbedInterface(

View File

@ -1,7 +1,7 @@
from books.schema.mutations.chapter import UpdateChapterVisibility
from books.schema.mutations.contentblock import DuplicateContentBlock, MutateContentBlock, AddContentBlock, \
DeleteContentBlock
from books.schema.mutations.module import UpdateSolutionVisibility, UpdateLastModule, SyncModuleVisibility
from books.schema.mutations.module import UpdateSolutionVisibility, UpdateLastModule, SyncModuleVisibility, UpdateLastModuleLevel
from books.schema.mutations.snapshot import CreateSnapshot, ApplySnapshot, ShareSnapshot, UpdateSnapshot, DeleteSnapshot
from books.schema.mutations.topic import UpdateLastTopic
@ -14,6 +14,7 @@ class BookMutations(object):
update_solution_visibility = UpdateSolutionVisibility.Field()
update_last_module = UpdateLastModule.Field()
update_last_topic = UpdateLastTopic.Field()
update_last_module_level = UpdateLastModuleLevel.Field()
update_chapter_visibility = UpdateChapterVisibility.Field()
sync_module_visibility = SyncModuleVisibility.Field()
create_snapshot = CreateSnapshot.Field()

View File

@ -1,13 +1,12 @@
from datetime import datetime
import graphene
from django.utils import timezone
from graphene import relay
from api.utils import get_object
from books.models import Module, RecentModule
from books.models import Module, RecentModule, ModuleLevel
from books.schema.nodes import ModuleNode
from users.models import SchoolClass
from users.models import SchoolClass, User
from users.schema import PrivateUserNode
class UpdateSolutionVisibility(relay.ClientIDMutation):
@ -104,3 +103,19 @@ class SyncModuleVisibility(relay.ClientIDMutation):
module.sync_from_school_class(template, school_class)
return cls(success=True)
class UpdateLastModuleLevel(relay.ClientIDMutation):
class Input:
id = graphene.ID()
user = graphene.Field(PrivateUserNode)
@classmethod
def mutate_and_get_payload(cls, root, info, **args):
user = info.context.user
id = args.get('id')
module_level = get_object(ModuleLevel, id)
User.objects.filter(pk=user.id).update(last_module_level_id=module_level.id)
return cls(user=user)

View File

@ -3,7 +3,7 @@ from graphene import relay
from api.types import FailureNode, Success
from api.utils import get_object
from books.models import Module, ContentBlock, Chapter
from books.models import Module
from books.models.snapshot import Snapshot
from books.schema.nodes import SnapshotNode, ModuleNode
from users.models import SchoolClass

View File

@ -6,9 +6,16 @@ from graphene_django.filter import DjangoFilterConnectionField
from assignments.models import StudentSubmission
from assignments.schema.types import AssignmentNode, StudentSubmissionNode
from books.models import Module, Chapter, ContentBlock, RecentModule
from books.models import (
Module,
Chapter,
ContentBlock,
RecentModule,
)
from books.schema.interfaces.module import ModuleInterface
from books.schema.nodes.chapter import ChapterNode
from books.schema.nodes.module_category import ModuleCategoryNode
from books.schema.nodes.module_level import ModuleLevelNode
from notes.models import ModuleBookmark, ContentBlockBookmark, ChapterBookmark
from notes.schema import (
ModuleBookmarkNode,
@ -34,6 +41,8 @@ class ModuleNode(DjangoObjectType):
"hero_image",
"hero_source",
"topic",
"level",
"category",
]
filter_fields = {
"slug": ["exact", "icontains", "in"],
@ -46,12 +55,13 @@ class ModuleNode(DjangoObjectType):
bookmark = graphene.Field(ModuleBookmarkNode)
my_submissions = DjangoFilterConnectionField(StudentSubmissionNode)
my_answers = DjangoFilterConnectionField(AnswerNode)
my_content_bookmarks = DjangoFilterConnectionField(
ContentBlockBookmarkNode)
my_content_bookmarks = DjangoFilterConnectionField(ContentBlockBookmarkNode)
my_chapter_bookmarks = DjangoFilterConnectionField(ChapterBookmarkNode)
snapshots = graphene.List("books.schema.nodes.SnapshotNode")
objective_groups = graphene.List(ObjectiveGroupNode)
assignments = graphene.List(AssignmentNode)
level = graphene.Field(ModuleLevelNode)
category = graphene.Field(ModuleCategoryNode)
def resolve_chapters(self, info, **kwargs):
return Chapter.get_by_parent(self)

View File

@ -0,0 +1,11 @@
from graphene import relay
from graphene_django import DjangoObjectType
from books.models import ModuleCategory
class ModuleCategoryNode(DjangoObjectType):
class Meta:
model = ModuleCategory
interfaces = (relay.Node,)
only_fields = "__all__"

View File

@ -0,0 +1,11 @@
from graphene import relay
from graphene_django import DjangoObjectType
from books.models import ModuleLevel
class ModuleLevelNode(DjangoObjectType):
class Meta:
model = ModuleLevel
interfaces = (relay.Node,)
only_fields = "__all__"

View File

@ -5,9 +5,17 @@ from graphene_django.filter import DjangoFilterConnectionField
from api.utils import get_object
from core.logger import get_logger
from .connections import TopicConnection, ModuleConnection
from .nodes import ContentBlockNode, ChapterNode, ModuleNode, NotFoundFailure, SnapshotNode, \
TopicOr404Node
from ..models import Book, Topic, Module, Chapter, Snapshot
from .nodes import (
ContentBlockNode,
ChapterNode,
ModuleNode,
NotFoundFailure,
SnapshotNode,
TopicOr404Node,
)
from .nodes.module_category import ModuleCategoryNode
from .nodes.module_level import ModuleLevelNode
from ..models import Book, Topic, Module, Chapter, Snapshot, ModuleLevel, ModuleCategory
logger = get_logger(__name__)
@ -15,8 +23,7 @@ logger = get_logger(__name__)
class BookQuery(object):
node = relay.Node.Field()
topic = graphene.Field(TopicOr404Node, slug=graphene.String())
module = graphene.Field(
ModuleNode, slug=graphene.String(), id=graphene.ID())
module = graphene.Field(ModuleNode, slug=graphene.String(), id=graphene.ID())
chapter = relay.Node.Field(ChapterNode)
content_block = relay.Node.Field(ContentBlockNode)
snapshot = relay.Node.Field(SnapshotNode)
@ -25,6 +32,12 @@ class BookQuery(object):
modules = relay.ConnectionField(ModuleConnection)
chapters = DjangoFilterConnectionField(ChapterNode)
module_level = graphene.Field(ModuleLevelNode, id=graphene.ID(required=True))
module_levels = graphene.List(ModuleLevelNode)
module_category = graphene.Field(ModuleCategoryNode, id=graphene.ID(required=True))
module_categories = graphene.List(ModuleCategoryNode)
def resolve_books(self, *args, **kwargs):
return Book.objects.filter(**kwargs).live()
@ -38,13 +51,13 @@ class BookQuery(object):
return Chapter.objects.filter(**kwargs).live()
def resolve_snapshot(self, info, **kwargs):
id = kwargs.get('id')
id = kwargs.get("id")
snapshot = get_object(Snapshot, id)
return snapshot
def resolve_module(self, info, **kwargs):
slug = kwargs.get('slug')
id = kwargs.get('id')
slug = kwargs.get("slug")
id = kwargs.get("id")
module = None
try:
if id is not None:
@ -59,8 +72,8 @@ class BookQuery(object):
return None
def resolve_topic(self, info, **kwargs):
slug = kwargs.get('slug')
id = kwargs.get('id')
slug = kwargs.get("slug")
id = kwargs.get("id")
if id is not None:
return get_object(Topic, id)
@ -70,3 +83,33 @@ class BookQuery(object):
except Topic.DoesNotExist:
return NotFoundFailure
return None
def resolve_module_level(self, info, **kwargs):
module_level_id = kwargs.get("id")
try:
if module_level_id is not None:
return get_object(Module, module_level_id)
except Module.DoesNotExist:
return None
def resolve_module_levels(self, *args, **kwargs):
return ModuleLevel.objects.all()
def resolve_module_category(self, info, **kwargs):
id = kwargs.get("id")
try:
if id is not None:
return get_object(Module, id)
except Module.DoesNotExist:
return None
def resolve_module_categories(self, *args, **kwargs):
return ModuleCategory.objects.all()
class ModuleTypeQuery(graphene.ObjectType):
node = relay.Node.Field()
name = graphene.String()
id = graphene.ID()

View File

@ -1,12 +1,14 @@
from django.test import TestCase, RequestFactory
from unittest import skip
from graphene.test import Client
from graphql_relay import to_global_id
from api.schema import schema
from api.utils import get_object
from books.models import ContentBlock, Chapter
from books.factories import ModuleFactory
from books.factories import ModuleFactory, ModuleLevelFactory
from core.factories import UserFactory
from users.models import User
class NewContentBlockMutationTest(TestCase):
@ -14,12 +16,12 @@ class NewContentBlockMutationTest(TestCase):
module = ModuleFactory()
chapter = Chapter(title='Hello')
module.add_child(instance=chapter)
user = UserFactory(username='aschi')
self.user = UserFactory(username='aschi')
content_block = ContentBlock(title='bla', slug='bla')
chapter.specific.add_child(instance=content_block)
request = RequestFactory().get('/')
request.user = user
request.user = self.user
self.client = Client(schema=schema, context_value=request)
self.sibling_id = to_global_id('ContentBlockNode', content_block.pk)
@ -120,3 +122,30 @@ class NewContentBlockMutationTest(TestCase):
content = new_content_block['contents'][0]
self.assertEqual(content.get('type'), 'image_url_block')
self.assertEqual(content.get('value'), {'url': '/test.png'})
def test_updateLastModuleLevel(self):
self.assertIsNone(self.user.last_module_level, None)
moduleLevel = ModuleLevelFactory(name='1. Lehrjahr')
moduleLevel1 = ModuleLevelFactory(name='2. Lehrjahr')
mutation = """
mutation ($input: UpdateLastModuleLevelInput!) {
updateLastModuleLevel(input: $input) {
clientMutationId
user {
username
lastModuleLevel {
name
id
}
}
}
}
"""
module_level_gql_id = to_global_id('ModuleLevelNode', moduleLevel1.pk)
result = self.client.execute(mutation, variables={"input": {"id": module_level_gql_id}})
self.assertIsNone(result.get('errors'))
updated_user = User.objects.get(id=self.user.id)
self.assertEqual(updated_user.last_module_level.name, moduleLevel1.name)

View File

@ -0,0 +1,39 @@
from wagtail.contrib.modeladmin.options import (
ModelAdmin,
ModelAdminGroup,
modeladmin_register,
)
from .models.module import ModuleLevel, ModuleCategory, Module
from django.utils.translation import gettext_lazy as _
class ModuleAdmin(ModelAdmin):
model = Module
list_display = ("title", "meta_title", "level", "category")
search_fields = ("title", "meta_title")
list_filter = ("level", "category")
class ModuleLevelAdmin(ModelAdmin):
model = ModuleLevel
list_display = ("name",)
ordering = ("name",)
class ModuleCategoryAdmin(ModelAdmin):
model = ModuleCategory
list_display = ("name",)
ordering = ("name",)
inspect_view_fields = ("name",)
class InstrumentGroup(ModelAdminGroup):
menu_label = _("Modules")
items = (
ModuleAdmin,
ModuleLevelAdmin,
ModuleCategoryAdmin,
)
modeladmin_register(InstrumentGroup)

6
server/graphql-schema-macos.sh Executable file
View File

@ -0,0 +1,6 @@
#!/bin/bash
python manage.py graphql_schema
sed -i '' 's/Node, Node/Node & Node/g' schema.graphql
python manage.py graphql_schema --schema api.schema_public.schema --out schema-public.graphql
sed -i '' 's/Node, Node/Node & Node/g' schema-public.graphql

View File

@ -236,7 +236,7 @@ interface ChapterInterface {
title: String
}
type ChapterNode implements Node & ChapterInterface {
type ChapterNode implements Node, ChapterInterface {
title: String
slug: String!
description: String
@ -306,7 +306,7 @@ interface ContentBlockInterface {
type: String
}
type ContentBlockNode implements Node & ContentBlockInterface {
type ContentBlockNode implements Node, ContentBlockInterface {
title: String
slug: String!
hiddenFor: [SchoolClassNode]
@ -621,6 +621,17 @@ type ModuleBookmarkNode {
module: ModuleNode!
}
enum ModuleCategoryFilterAttributeType {
ALL
EXACT
}
type ModuleCategoryNode implements Node {
id: ID!
name: String!
filterAttributeType: ModuleCategoryFilterAttributeType!
}
type ModuleConnection {
pageInfo: PageInfo!
edges: [ModuleEdge]!
@ -639,10 +650,23 @@ interface ModuleInterface {
topic: TopicNode
}
enum ModuleLevelFilterAttributeType {
ALL
EXACT
}
type ModuleLevelNode implements Node {
id: ID!
name: String!
filterAttributeType: ModuleLevelFilterAttributeType!
}
type ModuleNode implements ModuleInterface {
title: String!
slug: String!
metaTitle: String!
level: ModuleLevelNode
category: ModuleCategoryNode
heroImage: String!
heroSource: String!
teaser: String!
@ -735,6 +759,7 @@ type Mutation {
updateSolutionVisibility(input: UpdateSolutionVisibilityInput!): UpdateSolutionVisibilityPayload
updateLastModule(input: UpdateLastModuleInput!): UpdateLastModulePayload
updateLastTopic(input: UpdateLastTopicInput!): UpdateLastTopicPayload
updateLastModuleLevel(input: UpdateLastModuleLevelInput!): UpdateLastModuleLevelPayload
updateChapterVisibility(input: UpdateChapterVisibilityInput!): UpdateChapterVisibilityPayload
syncModuleVisibility(input: SyncModuleVisibilityInput!): SyncModuleVisibilityPayload
createSnapshot(input: CreateSnapshotInput!): CreateSnapshotPayload
@ -847,6 +872,7 @@ type PrivateUserNode implements Node {
avatarUrl: String!
username: String!
lastModule: ModuleNode
lastModuleLevel: ModuleLevelNode
lastTopic: TopicNode
email: String!
onboardingVisited: Boolean!
@ -928,6 +954,10 @@ type Query {
topics(before: String, after: String, first: Int, last: Int): TopicConnection
modules(before: String, after: String, first: Int, last: Int): ModuleConnection
chapters(offset: Int, before: String, after: String, first: Int, last: Int, slug: String, title: String): ChapterNodeConnection
moduleLevel(id: ID!): ModuleLevelNode
moduleLevels: [ModuleLevelNode]
moduleCategory(id: ID!): ModuleCategoryNode
moduleCategories: [ModuleCategoryNode]
objectiveGroup(id: ID!): ObjectiveGroupNode
objectiveGroups(offset: Int, before: String, after: String, first: Int, last: Int, title: String, module_Slug: String): ObjectiveGroupNodeConnection
roomEntry(id: ID, slug: String): RoomEntryNode
@ -1013,7 +1043,7 @@ type SnapshotChangesNode {
newContentBlocks: Int!
}
type SnapshotChapterNode implements Node & ChapterInterface {
type SnapshotChapterNode implements Node, ChapterInterface {
id: ID!
description: String
title: String
@ -1022,7 +1052,7 @@ type SnapshotChapterNode implements Node & ChapterInterface {
titleHidden: Boolean
}
type SnapshotContentBlockNode implements Node & ContentBlockInterface {
type SnapshotContentBlockNode implements Node, ContentBlockInterface {
id: ID!
title: String
contents: GenericStreamFieldType
@ -1306,6 +1336,16 @@ input UpdateLastModuleInput {
clientMutationId: String
}
input UpdateLastModuleLevelInput {
id: ID
clientMutationId: String
}
type UpdateLastModuleLevelPayload {
user: PrivateUserNode
clientMutationId: String
}
type UpdateLastModulePayload {
lastModule: ModuleNode
clientMutationId: String

View File

@ -0,0 +1,20 @@
# Generated by Django 3.2.16 on 2023-08-22 12:19
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('books', '0043_auto_20230822_1219'),
('users', '0033_alter_license_isbn'),
]
operations = [
migrations.AddField(
model_name='user',
name='last_module_level',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to='books.modulelevel'),
),
]

View File

@ -33,6 +33,7 @@ class User(AbstractUser):
last_module = models.ForeignKey('books.Module', related_name='+', on_delete=models.SET_NULL, null=True)
recent_modules = models.ManyToManyField('books.Module', related_name='+', through='books.RecentModule')
last_module_level = models.ForeignKey('books.ModuleLevel', related_name='+', on_delete=models.SET_NULL, null=True)
last_topic = models.ForeignKey('books.Topic', related_name='+', on_delete=models.SET_NULL, null=True)
avatar_url = models.CharField(max_length=254, blank=True, default='')
email = models.EmailField(_('email address'), unique=True)

View File

@ -11,6 +11,7 @@ from graphql_relay import to_global_id
from api.types import FailureNode
from books.models import Module
from books.schema.nodes import ModuleLevelNode
from books.schema.queries import ModuleNode
from users.models import SchoolClass, SchoolClassMember, Team, User
@ -103,6 +104,7 @@ class PrivateUserNode(DjangoObjectType):
"onboarding_visited",
"team",
"read_only",
"last_module_level"
]
interfaces = (relay.Node,)
@ -113,6 +115,7 @@ class PrivateUserNode(DjangoObjectType):
is_teacher = graphene.Boolean()
old_classes = graphene.List(SchoolClassNode)
school_classes = graphene.List(SchoolClassNode)
recent_modules = DjangoFilterConnectionField(
ModuleNode, filterset_class=RecentModuleFilter
)
@ -161,6 +164,7 @@ class PrivateUserNode(DjangoObjectType):
return self.team
class ClassMemberNode(ObjectType):
"""
We need to build this ourselves, because we want the active property on the node, because providing it on the