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" class="module"
v-if="module.id" v-if="module.id"
> >
<div class="module__header">
<h2 <h2
class="module__meta-title" class="module__meta-title"
id="meta-title" id="meta-title"
> >
{{ module.metaTitle }} {{ module.metaTitle }}
</h2> </h2>
<div class="module__categoryindicators">
<pill :text="module.level?.name"></pill>
<pill :text="module.category?.name"></pill>
</div>
</div>
<h1 <h1
class="module__title" class="module__title"
data-cy="module-title" data-cy="module-title"
@ -88,6 +98,7 @@
import ObjectiveGroups from '@/components/objective-groups/ObjectiveGroups.vue'; import ObjectiveGroups from '@/components/objective-groups/ObjectiveGroups.vue';
import Chapter from '@/components/Chapter.vue'; import Chapter from '@/components/Chapter.vue';
import BookmarkActions from '@/components/notes/BookmarkActions.vue'; import BookmarkActions from '@/components/notes/BookmarkActions.vue';
import Pill from "@/components/ui/Pill.vue";
export default { export default {
props: { props: {
@ -98,6 +109,7 @@ export default {
}, },
components: { components: {
Pill,
BookmarkActions, BookmarkActions,
ObjectiveGroups, ObjectiveGroups,
Chapter, Chapter,
@ -161,8 +173,17 @@ export default {
line-height: 25px; line-height: 25px;
} }
&__header {
display: flex;
justify-content: flex-start;
align-items: stretch;
margin-bottom: $small-spacing;
}
&__meta-title { &__meta-title {
@include meta-title; @include meta-title;
margin-right: $medium-spacing;
} }
&__intro-wrapper { &__intro-wrapper {

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"> <p class="module-teaser__description">
{{ teaser }} {{ teaser }}
</p> </p>
<div class="module-teaser__pills">
<pill :text="level?.name"></pill>
<pill :text="category?.name"></pill>
</div>
</div> </div>
</router-link> </router-link>
</template> </template>
<script> <script>
import Pill from "@/components/ui/Pill.vue";
export default { export default {
props: ['metaTitle', 'title', 'teaser', 'id', 'slug', 'heroImage'],
props: ['metaTitle', 'title', 'teaser', 'id', 'slug', 'heroImage', 'level', 'category'],
components: {Pill},
computed: { computed: {
moduleLink() { moduleLink() {
@ -48,7 +58,7 @@ export default {
.module-teaser { .module-teaser {
box-shadow: 0 3px 9px 0 rgba(0, 0, 0, 0.12); box-shadow: 0 3px 9px 0 rgba(0, 0, 0, 0.12);
border: 1px solid #e2e2e2; border: 1px solid #e2e2e2;
height: 330px; height: 390px;
max-width: 380px; max-width: 380px;
width: 100%; width: 100%;
border-radius: 12px; border-radius: 12px;
@ -88,5 +98,9 @@ export default {
line-height: $default-line-height; line-height: $default-line-height;
font-size: 1.2rem; font-size: 1.2rem;
} }
&__pills {
margin-top: $medium-spacing;
}
} }
</style> </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 heroSource
solutionsEnabled solutionsEnabled
inEditMode @client inEditMode @client
level {
id
name
}
category {
id
name
}
topic { topic {
slug slug
title title

View File

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

View File

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

View File

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

99
package-lock.json generated
View File

@ -1,101 +1,16 @@
{ {
"name": "cariot", "name": "myskillbox",
"version": "1.0.1", "version": "1.1.0",
"lockfileVersion": 2, "lockfileVersion": 2,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "cariot", "name": "myskillbox",
"version": "1.0.1", "version": "1.1.0",
"dependencies": {
"babel-polyfill": "^6.26.0",
"unfetch": "^3.0.0"
},
"engines": { "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, SurveyBlock,
VideoBlock, 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 ( from core.factories import (
BasePageFactory, BasePageFactory,
DummyImageFactory, DummyImageFactory,
@ -201,6 +201,17 @@ class VideoBlockFactory(wagtail_factories.StructBlockFactory):
class Meta: class Meta:
model = VideoBlock 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 = [ block_types = [
"text_block", "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.db import models
from django.utils import timezone 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 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.constants import DEFAULT_RICH_TEXT_FEATURES
from core.wagtail_utils import StrictHierarchyPage, get_default_settings from core.wagtail_utils import StrictHierarchyPage, get_default_settings
from users.models import SchoolClass 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 Module(StrictHierarchyPage):
class Meta: class Meta:
@ -15,6 +48,13 @@ class Module(StrictHierarchyPage):
verbose_name_plural = "Module" verbose_name_plural = "Module"
meta_title = models.CharField(max_length=255, help_text="e.g. 'Intro' or 'Modul 1'") 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( hero_image = models.ForeignKey(
"wagtailimages.Image", "wagtailimages.Image",
null=True, null=True,
@ -34,30 +74,12 @@ class Module(StrictHierarchyPage):
content_panels = [ content_panels = [
FieldPanel("title", classname="full title"), FieldPanel("title", classname="full title"),
FieldPanel("meta_title", classname="full title"), FieldPanel("meta_title", classname="full title"),
FieldPanel("level"),
FieldPanel("category"),
FieldPanel("hero_image"), FieldPanel("hero_image"),
FieldPanel("hero_source"), FieldPanel("hero_source"),
FieldPanel("teaser"), FieldPanel("teaser"),
FieldPanel("intro"), 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( edit_handler = TabbedInterface(

View File

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

View File

@ -1,13 +1,12 @@
from datetime import datetime
import graphene import graphene
from django.utils import timezone from django.utils import timezone
from graphene import relay from graphene import relay
from api.utils import get_object 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 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): class UpdateSolutionVisibility(relay.ClientIDMutation):
@ -104,3 +103,19 @@ class SyncModuleVisibility(relay.ClientIDMutation):
module.sync_from_school_class(template, school_class) module.sync_from_school_class(template, school_class)
return cls(success=True) 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.types import FailureNode, Success
from api.utils import get_object 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.models.snapshot import Snapshot
from books.schema.nodes import SnapshotNode, ModuleNode from books.schema.nodes import SnapshotNode, ModuleNode
from users.models import SchoolClass from users.models import SchoolClass

View File

@ -6,9 +6,16 @@ from graphene_django.filter import DjangoFilterConnectionField
from assignments.models import StudentSubmission from assignments.models import StudentSubmission
from assignments.schema.types import AssignmentNode, StudentSubmissionNode 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.interfaces.module import ModuleInterface
from books.schema.nodes.chapter import ChapterNode 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.models import ModuleBookmark, ContentBlockBookmark, ChapterBookmark
from notes.schema import ( from notes.schema import (
ModuleBookmarkNode, ModuleBookmarkNode,
@ -34,6 +41,8 @@ class ModuleNode(DjangoObjectType):
"hero_image", "hero_image",
"hero_source", "hero_source",
"topic", "topic",
"level",
"category",
] ]
filter_fields = { filter_fields = {
"slug": ["exact", "icontains", "in"], "slug": ["exact", "icontains", "in"],
@ -46,12 +55,13 @@ class ModuleNode(DjangoObjectType):
bookmark = graphene.Field(ModuleBookmarkNode) bookmark = graphene.Field(ModuleBookmarkNode)
my_submissions = DjangoFilterConnectionField(StudentSubmissionNode) my_submissions = DjangoFilterConnectionField(StudentSubmissionNode)
my_answers = DjangoFilterConnectionField(AnswerNode) my_answers = DjangoFilterConnectionField(AnswerNode)
my_content_bookmarks = DjangoFilterConnectionField( my_content_bookmarks = DjangoFilterConnectionField(ContentBlockBookmarkNode)
ContentBlockBookmarkNode)
my_chapter_bookmarks = DjangoFilterConnectionField(ChapterBookmarkNode) my_chapter_bookmarks = DjangoFilterConnectionField(ChapterBookmarkNode)
snapshots = graphene.List("books.schema.nodes.SnapshotNode") snapshots = graphene.List("books.schema.nodes.SnapshotNode")
objective_groups = graphene.List(ObjectiveGroupNode) objective_groups = graphene.List(ObjectiveGroupNode)
assignments = graphene.List(AssignmentNode) assignments = graphene.List(AssignmentNode)
level = graphene.Field(ModuleLevelNode)
category = graphene.Field(ModuleCategoryNode)
def resolve_chapters(self, info, **kwargs): def resolve_chapters(self, info, **kwargs):
return Chapter.get_by_parent(self) 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 api.utils import get_object
from core.logger import get_logger from core.logger import get_logger
from .connections import TopicConnection, ModuleConnection from .connections import TopicConnection, ModuleConnection
from .nodes import ContentBlockNode, ChapterNode, ModuleNode, NotFoundFailure, SnapshotNode, \ from .nodes import (
TopicOr404Node ContentBlockNode,
from ..models import Book, Topic, Module, Chapter, Snapshot 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__) logger = get_logger(__name__)
@ -15,8 +23,7 @@ logger = get_logger(__name__)
class BookQuery(object): class BookQuery(object):
node = relay.Node.Field() node = relay.Node.Field()
topic = graphene.Field(TopicOr404Node, slug=graphene.String()) topic = graphene.Field(TopicOr404Node, slug=graphene.String())
module = graphene.Field( module = graphene.Field(ModuleNode, slug=graphene.String(), id=graphene.ID())
ModuleNode, slug=graphene.String(), id=graphene.ID())
chapter = relay.Node.Field(ChapterNode) chapter = relay.Node.Field(ChapterNode)
content_block = relay.Node.Field(ContentBlockNode) content_block = relay.Node.Field(ContentBlockNode)
snapshot = relay.Node.Field(SnapshotNode) snapshot = relay.Node.Field(SnapshotNode)
@ -25,6 +32,12 @@ class BookQuery(object):
modules = relay.ConnectionField(ModuleConnection) modules = relay.ConnectionField(ModuleConnection)
chapters = DjangoFilterConnectionField(ChapterNode) 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): def resolve_books(self, *args, **kwargs):
return Book.objects.filter(**kwargs).live() return Book.objects.filter(**kwargs).live()
@ -38,13 +51,13 @@ class BookQuery(object):
return Chapter.objects.filter(**kwargs).live() return Chapter.objects.filter(**kwargs).live()
def resolve_snapshot(self, info, **kwargs): def resolve_snapshot(self, info, **kwargs):
id = kwargs.get('id') id = kwargs.get("id")
snapshot = get_object(Snapshot, id) snapshot = get_object(Snapshot, id)
return snapshot return snapshot
def resolve_module(self, info, **kwargs): def resolve_module(self, info, **kwargs):
slug = kwargs.get('slug') slug = kwargs.get("slug")
id = kwargs.get('id') id = kwargs.get("id")
module = None module = None
try: try:
if id is not None: if id is not None:
@ -59,8 +72,8 @@ class BookQuery(object):
return None return None
def resolve_topic(self, info, **kwargs): def resolve_topic(self, info, **kwargs):
slug = kwargs.get('slug') slug = kwargs.get("slug")
id = kwargs.get('id') id = kwargs.get("id")
if id is not None: if id is not None:
return get_object(Topic, id) return get_object(Topic, id)
@ -70,3 +83,33 @@ class BookQuery(object):
except Topic.DoesNotExist: except Topic.DoesNotExist:
return NotFoundFailure return NotFoundFailure
return None 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 django.test import TestCase, RequestFactory
from unittest import skip
from graphene.test import Client from graphene.test import Client
from graphql_relay import to_global_id from graphql_relay import to_global_id
from api.schema import schema from api.schema import schema
from api.utils import get_object from api.utils import get_object
from books.models import ContentBlock, Chapter from books.models import ContentBlock, Chapter
from books.factories import ModuleFactory from books.factories import ModuleFactory, ModuleLevelFactory
from core.factories import UserFactory from core.factories import UserFactory
from users.models import User
class NewContentBlockMutationTest(TestCase): class NewContentBlockMutationTest(TestCase):
@ -14,12 +16,12 @@ class NewContentBlockMutationTest(TestCase):
module = ModuleFactory() module = ModuleFactory()
chapter = Chapter(title='Hello') chapter = Chapter(title='Hello')
module.add_child(instance=chapter) module.add_child(instance=chapter)
user = UserFactory(username='aschi') self.user = UserFactory(username='aschi')
content_block = ContentBlock(title='bla', slug='bla') content_block = ContentBlock(title='bla', slug='bla')
chapter.specific.add_child(instance=content_block) chapter.specific.add_child(instance=content_block)
request = RequestFactory().get('/') request = RequestFactory().get('/')
request.user = user request.user = self.user
self.client = Client(schema=schema, context_value=request) self.client = Client(schema=schema, context_value=request)
self.sibling_id = to_global_id('ContentBlockNode', content_block.pk) self.sibling_id = to_global_id('ContentBlockNode', content_block.pk)
@ -120,3 +122,30 @@ class NewContentBlockMutationTest(TestCase):
content = new_content_block['contents'][0] content = new_content_block['contents'][0]
self.assertEqual(content.get('type'), 'image_url_block') self.assertEqual(content.get('type'), 'image_url_block')
self.assertEqual(content.get('value'), {'url': '/test.png'}) 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 title: String
} }
type ChapterNode implements Node & ChapterInterface { type ChapterNode implements Node, ChapterInterface {
title: String title: String
slug: String! slug: String!
description: String description: String
@ -306,7 +306,7 @@ interface ContentBlockInterface {
type: String type: String
} }
type ContentBlockNode implements Node & ContentBlockInterface { type ContentBlockNode implements Node, ContentBlockInterface {
title: String title: String
slug: String! slug: String!
hiddenFor: [SchoolClassNode] hiddenFor: [SchoolClassNode]
@ -621,6 +621,17 @@ type ModuleBookmarkNode {
module: ModuleNode! module: ModuleNode!
} }
enum ModuleCategoryFilterAttributeType {
ALL
EXACT
}
type ModuleCategoryNode implements Node {
id: ID!
name: String!
filterAttributeType: ModuleCategoryFilterAttributeType!
}
type ModuleConnection { type ModuleConnection {
pageInfo: PageInfo! pageInfo: PageInfo!
edges: [ModuleEdge]! edges: [ModuleEdge]!
@ -639,10 +650,23 @@ interface ModuleInterface {
topic: TopicNode topic: TopicNode
} }
enum ModuleLevelFilterAttributeType {
ALL
EXACT
}
type ModuleLevelNode implements Node {
id: ID!
name: String!
filterAttributeType: ModuleLevelFilterAttributeType!
}
type ModuleNode implements ModuleInterface { type ModuleNode implements ModuleInterface {
title: String! title: String!
slug: String! slug: String!
metaTitle: String! metaTitle: String!
level: ModuleLevelNode
category: ModuleCategoryNode
heroImage: String! heroImage: String!
heroSource: String! heroSource: String!
teaser: String! teaser: String!
@ -735,6 +759,7 @@ type Mutation {
updateSolutionVisibility(input: UpdateSolutionVisibilityInput!): UpdateSolutionVisibilityPayload updateSolutionVisibility(input: UpdateSolutionVisibilityInput!): UpdateSolutionVisibilityPayload
updateLastModule(input: UpdateLastModuleInput!): UpdateLastModulePayload updateLastModule(input: UpdateLastModuleInput!): UpdateLastModulePayload
updateLastTopic(input: UpdateLastTopicInput!): UpdateLastTopicPayload updateLastTopic(input: UpdateLastTopicInput!): UpdateLastTopicPayload
updateLastModuleLevel(input: UpdateLastModuleLevelInput!): UpdateLastModuleLevelPayload
updateChapterVisibility(input: UpdateChapterVisibilityInput!): UpdateChapterVisibilityPayload updateChapterVisibility(input: UpdateChapterVisibilityInput!): UpdateChapterVisibilityPayload
syncModuleVisibility(input: SyncModuleVisibilityInput!): SyncModuleVisibilityPayload syncModuleVisibility(input: SyncModuleVisibilityInput!): SyncModuleVisibilityPayload
createSnapshot(input: CreateSnapshotInput!): CreateSnapshotPayload createSnapshot(input: CreateSnapshotInput!): CreateSnapshotPayload
@ -847,6 +872,7 @@ type PrivateUserNode implements Node {
avatarUrl: String! avatarUrl: String!
username: String! username: String!
lastModule: ModuleNode lastModule: ModuleNode
lastModuleLevel: ModuleLevelNode
lastTopic: TopicNode lastTopic: TopicNode
email: String! email: String!
onboardingVisited: Boolean! onboardingVisited: Boolean!
@ -928,6 +954,10 @@ type Query {
topics(before: String, after: String, first: Int, last: Int): TopicConnection topics(before: String, after: String, first: Int, last: Int): TopicConnection
modules(before: String, after: String, first: Int, last: Int): ModuleConnection 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 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 objectiveGroup(id: ID!): ObjectiveGroupNode
objectiveGroups(offset: Int, before: String, after: String, first: Int, last: Int, title: String, module_Slug: String): ObjectiveGroupNodeConnection objectiveGroups(offset: Int, before: String, after: String, first: Int, last: Int, title: String, module_Slug: String): ObjectiveGroupNodeConnection
roomEntry(id: ID, slug: String): RoomEntryNode roomEntry(id: ID, slug: String): RoomEntryNode
@ -1013,7 +1043,7 @@ type SnapshotChangesNode {
newContentBlocks: Int! newContentBlocks: Int!
} }
type SnapshotChapterNode implements Node & ChapterInterface { type SnapshotChapterNode implements Node, ChapterInterface {
id: ID! id: ID!
description: String description: String
title: String title: String
@ -1022,7 +1052,7 @@ type SnapshotChapterNode implements Node & ChapterInterface {
titleHidden: Boolean titleHidden: Boolean
} }
type SnapshotContentBlockNode implements Node & ContentBlockInterface { type SnapshotContentBlockNode implements Node, ContentBlockInterface {
id: ID! id: ID!
title: String title: String
contents: GenericStreamFieldType contents: GenericStreamFieldType
@ -1306,6 +1336,16 @@ input UpdateLastModuleInput {
clientMutationId: String clientMutationId: String
} }
input UpdateLastModuleLevelInput {
id: ID
clientMutationId: String
}
type UpdateLastModuleLevelPayload {
user: PrivateUserNode
clientMutationId: String
}
type UpdateLastModulePayload { type UpdateLastModulePayload {
lastModule: ModuleNode lastModule: ModuleNode
clientMutationId: String 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) 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') 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) 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='') avatar_url = models.CharField(max_length=254, blank=True, default='')
email = models.EmailField(_('email address'), unique=True) 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 api.types import FailureNode
from books.models import Module from books.models import Module
from books.schema.nodes import ModuleLevelNode
from books.schema.queries import ModuleNode from books.schema.queries import ModuleNode
from users.models import SchoolClass, SchoolClassMember, Team, User from users.models import SchoolClass, SchoolClassMember, Team, User
@ -103,6 +104,7 @@ class PrivateUserNode(DjangoObjectType):
"onboarding_visited", "onboarding_visited",
"team", "team",
"read_only", "read_only",
"last_module_level"
] ]
interfaces = (relay.Node,) interfaces = (relay.Node,)
@ -113,6 +115,7 @@ class PrivateUserNode(DjangoObjectType):
is_teacher = graphene.Boolean() is_teacher = graphene.Boolean()
old_classes = graphene.List(SchoolClassNode) old_classes = graphene.List(SchoolClassNode)
school_classes = graphene.List(SchoolClassNode) school_classes = graphene.List(SchoolClassNode)
recent_modules = DjangoFilterConnectionField( recent_modules = DjangoFilterConnectionField(
ModuleNode, filterset_class=RecentModuleFilter ModuleNode, filterset_class=RecentModuleFilter
) )
@ -161,6 +164,7 @@ class PrivateUserNode(DjangoObjectType):
return self.team return self.team
class ClassMemberNode(ObjectType): class ClassMemberNode(ObjectType):
""" """
We need to build this ourselves, because we want the active property on the node, because providing it on the We need to build this ourselves, because we want the active property on the node, because providing it on the