Merge branch 'feature/MS-924-Wagtail-Image-Optimization' into develop

# Conflicts:
#	client/src/components/modules/Module.vue
This commit is contained in:
Lorenz Padberg 2024-05-03 15:11:18 +02:00
commit 69d3fa845b
26 changed files with 407 additions and 62 deletions

View File

@ -244,10 +244,24 @@ python manage.py export_schema_for_cypress
### Generate GraphQL SDL Document ### Generate GraphQL SDL Document
for linux:
```bash
cd server
./graphql-schema.sh && npm run codegen --../prefix client
``` ```
python manage.py export_schema_graphql
For macOS: (there is a problem with the sed command)
```bash
cd server
./macos-graphql-schema.sh && npm run codegen --../prefix client
``` ```
## Backup to S3 ## Backup to S3
From https://pawelurbanek.com/heroku-postgresql-s3-backup From https://pawelurbanek.com/heroku-postgresql-s3-backup

View File

@ -42,7 +42,8 @@ const documents = {
"\n fragment SchoolClassParts on SchoolClassNode {\n id\n name\n }\n": types.SchoolClassPartsFragmentDoc, "\n fragment SchoolClassParts on SchoolClassNode {\n id\n name\n }\n": types.SchoolClassPartsFragmentDoc,
"\n fragment UserParts on PrivateUserNode {\n id\n pk\n username\n email\n firstName\n lastName\n avatarUrl\n expiryDate\n readOnly\n lastModuleLevel {\n id\n name\n filterAttributeType\n }\n lastModule {\n id\n slug\n }\n lastTopic {\n id\n slug\n }\n selectedClass {\n id\n readOnly\n }\n recentModules(orderBy: \"-visited\") {\n edges {\n node {\n ...ModuleParts\n }\n }\n }\n schoolClasses {\n ...SchoolClassParts\n }\n }\n": types.UserPartsFragmentDoc, "\n fragment UserParts on PrivateUserNode {\n id\n pk\n username\n email\n firstName\n lastName\n avatarUrl\n expiryDate\n readOnly\n lastModuleLevel {\n id\n name\n filterAttributeType\n }\n lastModule {\n id\n slug\n }\n lastTopic {\n id\n slug\n }\n selectedClass {\n id\n readOnly\n }\n recentModules(orderBy: \"-visited\") {\n edges {\n node {\n ...ModuleParts\n }\n }\n }\n schoolClasses {\n ...SchoolClassParts\n }\n }\n": types.UserPartsFragmentDoc,
"\n fragment TeamParts on TeamNode {\n name\n code\n id\n members {\n firstName\n lastName\n id\n isMe\n }\n }\n": types.TeamPartsFragmentDoc, "\n fragment TeamParts on TeamNode {\n name\n code\n id\n members {\n firstName\n lastName\n id\n isMe\n }\n }\n": types.TeamPartsFragmentDoc,
"\n fragment ModuleParts on ModuleNode {\n id\n title\n metaTitle\n teaser\n intro\n slug\n heroImage\n heroSource\n solutionsEnabled\n highlights {\n ...HighlightParts\n }\n language\n inEditMode @client\n level {\n id\n name\n }\n category {\n id\n name\n }\n topic {\n slug\n title\n }\n bookmark {\n note {\n id\n text\n }\n }\n }\n": types.ModulePartsFragmentDoc, "\nfragment WagtailImageParts on WagtailImageNode {\n id\n src\n alt\n width\n height\n title\n srcset\n }\n": types.WagtailImagePartsFragmentDoc,
"\n fragment ModuleParts on ModuleNode {\n id\n title\n metaTitle\n teaser\n intro\n slug\n heroImage { ...WagtailImageParts }\n heroSource\n solutionsEnabled\n highlights {\n ...HighlightParts\n }\n language\n inEditMode @client\n level {\n id\n name\n }\n category {\n id\n name\n }\n topic {\n slug\n title\n }\n bookmark {\n note {\n id\n text\n }\n }\n }\n": types.ModulePartsFragmentDoc,
"\n query MeQuery {\n me {\n ...UserParts\n team {\n ...TeamParts\n }\n isTeacher\n permissions\n onboardingVisited\n }\n }\n ": types.MeQueryDocument, "\n query MeQuery {\n me {\n ...UserParts\n team {\n ...TeamParts\n }\n isTeacher\n permissions\n onboardingVisited\n }\n }\n ": types.MeQueryDocument,
"\n fragment InstrumentHighlightsWithIdOnlyFragment on InstrumentNode {\n highlights {\n id\n }\n }\n ": types.InstrumentHighlightsWithIdOnlyFragmentFragmentDoc, "\n fragment InstrumentHighlightsWithIdOnlyFragment on InstrumentNode {\n highlights {\n id\n }\n }\n ": types.InstrumentHighlightsWithIdOnlyFragmentFragmentDoc,
"\n fragment ChapterHighlightsWithIdOnlyFragment on ChapterNode {\n highlights {\n id\n }\n }\n ": types.ChapterHighlightsWithIdOnlyFragmentFragmentDoc, "\n fragment ChapterHighlightsWithIdOnlyFragment on ChapterNode {\n highlights {\n id\n }\n }\n ": types.ChapterHighlightsWithIdOnlyFragmentFragmentDoc,
@ -194,7 +195,11 @@ export function graphql(source: "\n fragment TeamParts on TeamNode {\n name\
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */
export function graphql(source: "\n fragment ModuleParts on ModuleNode {\n id\n title\n metaTitle\n teaser\n intro\n slug\n heroImage\n heroSource\n solutionsEnabled\n highlights {\n ...HighlightParts\n }\n language\n inEditMode @client\n level {\n id\n name\n }\n category {\n id\n name\n }\n topic {\n slug\n title\n }\n bookmark {\n note {\n id\n text\n }\n }\n }\n"): (typeof documents)["\n fragment ModuleParts on ModuleNode {\n id\n title\n metaTitle\n teaser\n intro\n slug\n heroImage\n heroSource\n solutionsEnabled\n highlights {\n ...HighlightParts\n }\n language\n inEditMode @client\n level {\n id\n name\n }\n category {\n id\n name\n }\n topic {\n slug\n title\n }\n bookmark {\n note {\n id\n text\n }\n }\n }\n"]; export function graphql(source: "\nfragment WagtailImageParts on WagtailImageNode {\n id\n src\n alt\n width\n height\n title\n srcset\n }\n"): (typeof documents)["\nfragment WagtailImageParts on WagtailImageNode {\n id\n src\n alt\n width\n height\n title\n srcset\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n fragment ModuleParts on ModuleNode {\n id\n title\n metaTitle\n teaser\n intro\n slug\n heroImage { ...WagtailImageParts }\n heroSource\n solutionsEnabled\n highlights {\n ...HighlightParts\n }\n language\n inEditMode @client\n level {\n id\n name\n }\n category {\n id\n name\n }\n topic {\n slug\n title\n }\n bookmark {\n note {\n id\n text\n }\n }\n }\n"): (typeof documents)["\n fragment ModuleParts on ModuleNode {\n id\n title\n metaTitle\n teaser\n intro\n slug\n heroImage { ...WagtailImageParts }\n heroSource\n solutionsEnabled\n highlights {\n ...HighlightParts\n }\n language\n inEditMode @client\n level {\n id\n name\n }\n category {\n id\n name\n }\n topic {\n slug\n title\n }\n bookmark {\n note {\n id\n text\n }\n }\n }\n"];
/** /**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/ */

File diff suppressed because one or more lines are too long

View File

@ -1,18 +1,24 @@
<template> <template>
<img <wagtail-image
:src="value.path" :src="value.src"
alt="" :srcset="value.srcset"
:alt="value.alt"
:original-height="value.height"
:original-width="value.width"
class="image-block" class="image-block"
@click="openFullscreen" @click="openFullscreen"
/> ></wagtail-image>
</template> </template>
<script> <script>
import WagtailImage from '@/components/ui/WagtailImage.vue';
export default { export default {
props: ['value'], props: ['value'],
components: { WagtailImage },
methods: { methods: {
openFullscreen() { openFullscreen() {
this.$store.dispatch('showFullscreenImage', this.value.path); this.$store.dispatch('showFullscreenImage', this.value.src);
}, },
}, },
}; };

View File

@ -1,8 +1,9 @@
<template> <template>
<img <img
:src="value.url" :src="value.url"
alt="" :srcset="srcset"
class="image-block" class="image-block"
alt=""
@click="openFullscreen" @click="openFullscreen"
/> />
</template> </template>
@ -10,6 +11,19 @@
<script> <script>
export default { export default {
props: ['value'], props: ['value'],
computed: {
srcset() {
if (this.value.url.includes('ucarecdn')) {
return (
this.value.url +
'-/resize/300/ 300w,' +
this.value.url +
'-/resize/800/ 800w,'
);
}
return this.value.url;
},
},
methods: { methods: {
openFullscreen() { openFullscreen() {
this.$store.dispatch('showFullscreenImage', this.value.url); this.$store.dispatch('showFullscreenImage', this.value.url);

View File

@ -28,11 +28,16 @@
{{ module.title }} {{ module.title }}
</h1> </h1>
<div class="module__hero"> <div class="module__hero">
<img <wagtail-image
:src="module.heroImage"
alt=""
class="module__hero-image" class="module__hero-image"
/> :src="module.heroImage.src"
:srcset="module.heroImage.srcset"
:original-width="module.heroImage.width"
:original-height="module.heroImage.height"
:alt="module.heroImage.alt"
></wagtail-image>
<h5 <h5
class="module__hero-source" class="module__hero-source"
v-if="module.heroSource" v-if="module.heroSource"
@ -87,6 +92,7 @@ import { graphql } from '@/__generated__';
import highlightSidebar from '@/helpers/highlight-sidebar'; import highlightSidebar from '@/helpers/highlight-sidebar';
import { doUpdateHighlight } from '@/graphql/mutations'; import { doUpdateHighlight } from '@/graphql/mutations';
import Mark from 'mark.js'; import Mark from 'mark.js';
import WagtailImage from '@/components/ui/WagtailImage.vue';
export interface Props { export interface Props {
module: ModuleNode; module: ModuleNode;

View File

@ -3,10 +3,13 @@
:to="moduleLink" :to="moduleLink"
:class="['module-teaser', { 'module-teaser--small': !teaser }]" :class="['module-teaser', { 'module-teaser--small': !teaser }]"
> >
<div <wagtail-image
:style="{ backgroundImage: 'url(' + heroImage + ')' }"
class="module-teaser__image" class="module-teaser__image"
/> :src="heroImage.src"
:srcset="heroImage.srcset"
:original-height="heroImage.height"
:original-width="heroImage.width"
></wagtail-image>
<div class="module-teaser__body"> <div class="module-teaser__body">
<div class="module-teaser__content"> <div class="module-teaser__content">
<h3 class="module-teaser__content-meta-title">{{ metaTitle }}</h3> <h3 class="module-teaser__content-meta-title">{{ metaTitle }}</h3>
@ -33,6 +36,7 @@
import Pill from '@/components/ui/Pill.vue'; import Pill from '@/components/ui/Pill.vue';
import { ModuleCategoryNode, ModuleLevelNode } from '@/__generated__/graphql'; import { ModuleCategoryNode, ModuleLevelNode } from '@/__generated__/graphql';
import { computed } from '@vue/reactivity'; import { computed } from '@vue/reactivity';
import WagtailImage from '@/components/ui/WagtailImage.vue';
export interface Props { export interface Props {
metaTitle: string; metaTitle: string;
@ -102,10 +106,7 @@ const moduleLink = computed(() => {
width: 100%; width: 100%;
max-height: 150px; max-height: 150px;
height: 150px; height: 150px;
background-position: center; //prevent image from shrinking
background-size: 100% auto;
background-repeat: no-repeat;
// prevent image from shrinking
flex-shrink: 0; flex-shrink: 0;
} }

View File

@ -0,0 +1,120 @@
<template>
<div class="wagtail-image">
<div
:class="['wagtail-image__background', { loaded: loaded }]"
:style="{ height: backgroundHeight }"
ref="imgElement"
>
<img
:src="props.src"
:srcset="props.srcset"
:alt="props.alt"
class="wagtail-image__image"
:sizes="computedSizes"
loading="eager"
v-show="loaded"
@load="handleLoad"
/>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, onMounted, onBeforeUnmount } from 'vue';
export interface Props {
src: string;
alt?: string;
originalWidth: number;
originalHeight: number;
srcset?: string;
}
const props = withDefaults(defineProps<Props>(), {
alt: '',
});
const imgElement = ref(null);
const width = ref(0);
const height = ref(0);
const loaded = ref(false);
const backgroundHeight = ref();
const scaledHeight = ref();
//
const updateDimensions = () => {
if (imgElement.value && imgElement.value.parentElement) {
const { clientWidth, clientHeight } = imgElement.value.parentElement;
width.value = clientWidth;
height.value = clientHeight;
}
calculateBackgroundHeight();
};
const calculateBackgroundHeight = () => {
// calculate the hight of the background so, that you see a gray box of correct height before image is loaded
if (width.value) {
const scalingFactor = width.value / props.originalWidth;
scaledHeight.value = Math.round(props.originalHeight * scalingFactor);
if (width.value) {
backgroundHeight.value = `${scaledHeight.value}px`;
return;
}
}
backgroundHeight.value = '100%';
};
const handleLoad = () => {
loaded.value = true; // Set loaded to true when the image loads
};
const computedSizes = computed(() => {
// the default set of image sizes is [160px, 320px, 800px, 1600px]
let size = '100vw';
if (width.value <= 300) {
size = '160px';
}
if (300 < width.value && width.value <= 600) {
size = '320px';
}
if (600 < width.value && width.value <= 1200) {
size = '800px';
}
return size;
});
onMounted(() => {
updateDimensions();
window.addEventListener('resize', updateDimensions);
});
onBeforeUnmount(() => {
window.removeEventListener('resize', updateDimensions);
});
</script>
<style scoped lang="scss">
@import 'styles/helpers';
.wagtail-image {
overflow: hidden;
height: 100%;
&__background {
max-height: 100%;
background-color: $color-silver-light;
}
&__image {
width: 100%;
max-height: 100%;
object-fit: cover; // Ensures the image covers the allocated area without distorting aspect ratio
object-position: center;
}
}
.wagtail-image__background.loaded {
background-color: transparent;
}
</style>

View File

@ -1,4 +1,6 @@
#import "./highlightParts.gql" #import "./highlightParts.gql"
#import "./wagtailImageParts.gql"
fragment ModuleLegacyParts on ModuleNode { fragment ModuleLegacyParts on ModuleNode {
id id
title title
@ -6,8 +8,10 @@ fragment ModuleLegacyParts on ModuleNode {
teaser teaser
intro intro
slug slug
heroImage
heroSource heroSource
heroImage {
...WagtailImageParts
}
solutionsEnabled solutionsEnabled
highlights { highlights {
...HighlightLegacyParts ...HighlightLegacyParts

View File

@ -0,0 +1,9 @@
fragment WagtailImageParts on WagtailImageNode {
id
src
alt
width
height
title
srcset
}

View File

@ -5,7 +5,12 @@ query SnapshotDetail($id: ID!) {
id id
title title
metaTitle metaTitle
heroImage heroImage {
id
src
srcset
alt
}
created created
changes { changes {
newContentBlocks newContentBlocks

View File

@ -90,6 +90,19 @@ graphql(`
} }
} }
`); `);
graphql(`
fragment WagtailImageParts on WagtailImageNode {
id
src
alt
width
height
title
srcset
}
`);
graphql(` graphql(`
fragment ModuleParts on ModuleNode { fragment ModuleParts on ModuleNode {
id id
@ -98,7 +111,7 @@ graphql(`
teaser teaser
intro intro
slug slug
heroImage heroImage { ...WagtailImageParts }
heroSource heroSource
solutionsEnabled solutionsEnabled
highlights { highlights {

View File

@ -0,0 +1,29 @@
# Files handling
This document describes how files are handled in this appication.
# Types of files
static files: files that are not changed by the application, e.g. images, fonts, etc.¨
### content documents:
Files that belong to the content and are managed by the content editors in the CMS (pdf, excel, word, etc.)
### user documents:
Files that are uploaded by the users (pdf, etc.). Therefore not visible in the CMS.
Images are handled seprately from documents since images require additional processing (resizing, cropping, etc.).
Visible in the django admin.
### content images:
Images that belong to the content and are managed by the content editors in the CMS.
## User documents
User documents and images hare handled by uploadcare. In the database we oly have the url.
## Content images
Content Images are served directly from S3. The permissions are handled by django. Request to django are checked and redirected to the S3 bucket.

View File

@ -1,12 +1,11 @@
# mysite/api/graphene_wagtail.py # mysite/api/graphene_wagtail.py
# Taken from https://github.com/patrick91/wagtail-ql/blob/master/backend/graphene_utils/converter.py and slightly adjusted # Taken from https://github.com/patrick91/wagtail-ql/blob/master/backend/graphene_utils/converter.py and slightly adjusted
import logging import logging
from wagtail.images.views.serve import generate_image_url
from graphene.types import Scalar from graphene.types import Scalar
from graphene_django.converter import convert_django_field from graphene_django.converter import convert_django_field
from graphql_relay import to_global_id from graphql_relay import to_global_id
from wagtail.fields import StreamField from wagtail.fields import StreamField
from wagtail.documents.models import Document
from wagtail.images.models import Image from wagtail.images.models import Image
from assignments.models import Assignment from assignments.models import Assignment
@ -14,6 +13,7 @@ from basicknowledge.models import BasicKnowledge
from books.models import CustomDocument from books.models import CustomDocument
from surveys.models import Survey from surveys.models import Survey
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@ -42,17 +42,35 @@ def get_document_json(document_id):
return None return None
def get_wagtail_image_dict(image_id: int) -> dict | None:
from books.schema.nodes import get_srcset, get_src
try:
image = Image.objects.get(id=image_id)
value = {
'value': image_id,
'id': image.id,
'src': get_src(image),
'alt': image.title,
'title': image.title,
'width': image.width,
'height': image.height,
'srcset': get_srcset(image),
}
return value
except Image.DoesNotExist:
logger.error('Image {} does not exist'.format(image_id))
return None
def augment_fields(raw_data): def augment_fields(raw_data):
for data in raw_data: for data in raw_data:
if isinstance(data, dict): if isinstance(data, dict):
_type = data['type'] _type = data['type']
if _type == 'image_block': if _type == 'image_block':
_value = data['value'] _value = data['value']
value = { value = get_wagtail_image_dict(_value)
# 'value': _value,
# 'id': d['id'],
'path': Image.objects.get(id=_value).file.url
}
data['value'] = value data['value'] = value
if _type == 'assignment': if _type == 'assignment':
_value = data['value'] _value = data['value']

View File

@ -1,17 +1,13 @@
import graphene import graphene
from graphene import relay from graphene import relay
from wagtail.images.models import Image
from api.graphene_wagtail import generate_image_url
class ModuleInterface(relay.Node): class ModuleInterface(relay.Node):
pk = graphene.Int() pk = graphene.Int()
hero_image = graphene.String(required=True) hero_image = graphene.Field('books.schema.nodes.WagtailImageNode', required=True)
topic = graphene.Field('books.schema.nodes.TopicNode') topic = graphene.Field('books.schema.nodes.TopicNode')
@staticmethod @staticmethod
def resolve_pk(parent, info, **kwargs): def resolve_pk(parent, info, **kwargs):
return parent.id return parent.id
@staticmethod
def resolve_hero_image(parent, info, **kwargs):
if parent.hero_image:
return parent.hero_image.file.url

View File

@ -3,4 +3,5 @@ from .module import *
from .content import * from .content import *
from .snapshot import * from .snapshot import *
from .topic import * from .topic import *
from .wagtail_image import *

View File

@ -10,6 +10,7 @@ from django.db.models import Q
from graphene import relay from graphene import relay
from graphene_django import DjangoObjectType from graphene_django import DjangoObjectType
from graphene_django.filter import DjangoFilterConnectionField from graphene_django.filter import DjangoFilterConnectionField
from notes.models import Highlight, ModuleBookmark from notes.models import Highlight, ModuleBookmark
from objectives.schema import ObjectiveGroupNode from objectives.schema import ObjectiveGroupNode
from surveys.models import Answer from surveys.models import Answer

View File

@ -4,6 +4,7 @@ from graphene import relay, ObjectType
from graphene_django import DjangoObjectType from graphene_django import DjangoObjectType
from books.models.snapshot import Snapshot from books.models.snapshot import Snapshot
from books.schema.nodes.wagtail_image import WagtailImageNode
from ..interfaces import ChapterInterface from ..interfaces import ChapterInterface
from ..interfaces.contentblock import ContentBlockInterface from ..interfaces.contentblock import ContentBlockInterface
from ...models import ContentBlock from ...models import ContentBlock
@ -116,7 +117,7 @@ class SnapshotNode(DjangoObjectType):
title = graphene.String() title = graphene.String()
chapters = graphene.List(SnapshotChapterNode) chapters = graphene.List(SnapshotChapterNode)
meta_title = graphene.String() meta_title = graphene.String()
hero_image = graphene.String() hero_image = graphene.Field(WagtailImageNode)
changes = graphene.Field(SnapshotChangesNode) changes = graphene.Field(SnapshotChangesNode)
mine = graphene.Boolean() mine = graphene.Boolean()
shared = graphene.Boolean(required=True) shared = graphene.Boolean(required=True)
@ -149,9 +150,7 @@ class SnapshotNode(DjangoObjectType):
@staticmethod @staticmethod
def resolve_hero_image(parent, info, **kwargs): def resolve_hero_image(parent, info, **kwargs):
if parent.module.hero_image: return parent.module.hero_image
return parent.module.hero_image.file.url
return ''
@staticmethod @staticmethod
def resolve_changes(parent, info, **kwargs): def resolve_changes(parent, info, **kwargs):

View File

@ -0,0 +1,44 @@
import graphene
from graphene_django import DjangoObjectType
from graphene import relay
from wagtail.images.models import Image
from wagtail.images.views.serve import generate_image_url
def get_srcset(image: Image) -> str:
return (
f"{generate_image_url(image, 'width-160')} 160w, "
f"{generate_image_url(image, 'width-320')} 320w, "
f"{generate_image_url(image, 'width-800')} 800w, "
f"{generate_image_url(image, 'width-1600')} 1600w"
)
def get_src(image: Image) -> str:
return generate_image_url(image, f'width-{min(3840, image.width)}')
class WagtailImageNode(DjangoObjectType):
class Meta:
model = Image
fields = [
"title",
"width",
"height",
]
interfaces = (relay.Node,)
src = graphene.String()
alt = graphene.String()
srcset = graphene.String()
def resolve_src(self, info):
return get_src(self)
def resolve_alt(self, info):
return self.title
def resolve_srcset(self, info):
return get_srcset(self)

View File

@ -3,6 +3,12 @@ query ModulesQuery($slug: String, $id: ID) {
module(slug: $slug, id: $id) { module(slug: $slug, id: $id) {
id id
title title
heroImage {
id
src
height
width
}
objectiveGroups { objectiveGroups {
objectives { objectives {
id id

View File

@ -266,7 +266,6 @@ AWS_S3_CUSTOM_DOMAIN = "{}.s3-{}.amazonaws.com".format(
) )
if USE_AWS: if USE_AWS:
STORAGE_BACKEND = "storages.backends.s3boto3.S3Boto3Storage" STORAGE_BACKEND = "storages.backends.s3boto3.S3Boto3Storage"
# use with cloudfront
MEDIA_URL = "https://%s/" % AWS_S3_CUSTOM_DOMAIN MEDIA_URL = "https://%s/" % AWS_S3_CUSTOM_DOMAIN
else: else:
STORAGE_BACKEND = "django.core.files.storage.FileSystemStorage" STORAGE_BACKEND = "django.core.files.storage.FileSystemStorage"
@ -388,6 +387,7 @@ WAGTAILSEARCH_BACKENDS = {
} }
} }
WAGTAILDOCS_DOCUMENT_MODEL = "books.CustomDocument" WAGTAILDOCS_DOCUMENT_MODEL = "books.CustomDocument"
WAGTAILADMIN_BASE_URL = "/cms/"
GRAPHQL_QUERIES_DIR = os.path.join( GRAPHQL_QUERIES_DIR = os.path.join(

View File

@ -1,12 +1,13 @@
from django.conf import settings from django.conf import settings
from django.urls import path, re_path, include
from django.conf.urls.static import static from django.conf.urls.static import static
from django.contrib import admin from django.contrib import admin
from django.contrib.auth.decorators import login_required
from django.urls import path, re_path, include
from django.views.generic import RedirectView from django.views.generic import RedirectView
from wagtail.admin import urls as wagtailadmin_urls
from wagtail import urls as wagtail_urls from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls from wagtail.documents import urls as wagtaildocs_urls
from wagtail.images.views.serve import ServeView
from wagtailautocomplete.urls.admin import urlpatterns as autocomplete_admin_urls from wagtailautocomplete.urls.admin import urlpatterns as autocomplete_admin_urls
from core import views from core import views
@ -17,12 +18,17 @@ urlpatterns = [
re_path(r"^guru/", admin.site.urls), re_path(r"^guru/", admin.site.urls),
re_path(r"^statistics/", include("statistics.urls", namespace="statistics")), re_path(r"^statistics/", include("statistics.urls", namespace="statistics")),
# wagtail # wagtail
re_path(r'^api/images/([^/]*)/(\d*)/([^/]*)/[^/]*$', login_required(ServeView.as_view(action='redirect')),
name='wagtailimages_serve'),
re_path(r"^cms/autocomplete/", include(autocomplete_admin_urls)), re_path(r"^cms/autocomplete/", include(autocomplete_admin_urls)),
re_path(r"^cms/pages/(\d+)/$", override_wagtailadmin_explore_default_ordering), re_path(r"^cms/pages/(\d+)/$", override_wagtailadmin_explore_default_ordering),
re_path(r"^cms/", include(wagtailadmin_urls)), re_path(r"^cms/", include(wagtailadmin_urls)),
re_path(r"^documents/", include(wagtaildocs_urls)), re_path(r"^documents/", include(wagtaildocs_urls)),
# graphql backend # graphql backend
re_path(r"^api/", include("api.urls", namespace="api")), re_path(r"^api/", include("api.urls", namespace="api")),
# favicon # favicon
re_path( re_path(
r"^favicon\.ico$", r"^favicon\.ico$",

View File

@ -1,9 +1,9 @@
import requests import requests
from core.logger import get_logger
from django.conf import settings from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponse
from django.http.request import HttpRequest from django.http.request import HttpRequest
from django.http.response import HttpResponse, HttpResponseRedirect from django.http.response import HttpResponseRedirect
from django.shortcuts import render from django.shortcuts import render
from django.views.decorators.csrf import ensure_csrf_cookie from django.views.decorators.csrf import ensure_csrf_cookie
from graphene_django.views import GraphQLView from graphene_django.views import GraphQLView
@ -11,6 +11,8 @@ from graphql import get_operation_ast, parse
from sentry_sdk.api import start_transaction from sentry_sdk.api import start_transaction
from wagtail.admin.views.pages import listing from wagtail.admin.views.pages import listing
from core.logger import get_logger
logger = get_logger(__name__) logger = get_logger(__name__)
@ -68,3 +70,4 @@ def override_wagtailadmin_explore_default_ordering(request, parent_page_id):
return HttpResponseRedirect(request.path_info + "?ordering=ord") return HttpResponseRedirect(request.path_info + "?ordering=ord")
return listing.IndexView.as_view()(request, parent_page_id) return listing.IndexView.as_view()(request, parent_page_id)

View File

@ -4,10 +4,10 @@ export DATABASE_URL=postgres://skillbox:skillbox@localhost:5432/skillbox
export DEBUG=True export DEBUG=True
export ENABLE_SILKY=False export ENABLE_SILKY=False
export SECRET_KEY=FOOBAR export SECRET_KEY=FOOBAR
export USE_AWS=False export USE_AWS=True
export WAGTAILADMIN_BASE_URL=/ export WAGTAILADMIN_BASE_URL=/
export ALLOW_BETA_LOGIN=True export ALLOW_BETA_LOGIN=True
export USE_404_FALLBACK_IMAGE=True export AWS_STORAGE_BUCKET_NAME=skillbox-files-preprod
export AWS_REGION=eu-central-1
#export THEME=my-kv #export THEME=my-kv
#export APP_FLAVOR=my-kv #export APP_FLAVOR=my-kv

View File

@ -108,7 +108,7 @@ type ModuleNode implements ModuleInterface {
metaTitle: String! metaTitle: String!
level: ModuleLevelNode level: ModuleLevelNode
category: ModuleCategoryNode category: ModuleCategoryNode
heroImage: String! heroImage: WagtailImageNode!
"""e.g. 'Reuters', 'Wikipedia'""" """e.g. 'Reuters', 'Wikipedia'"""
heroSource: String! heroSource: String!
@ -140,10 +140,22 @@ interface ModuleInterface {
"""The ID of the object""" """The ID of the object"""
id: ID! id: ID!
pk: Int pk: Int
heroImage: String! heroImage: WagtailImageNode!
topic: TopicNode topic: TopicNode
} }
type WagtailImageNode implements Node {
title: String!
width: Int!
height: Int!
"""The ID of the object"""
id: ID!
src: String
alt: String
srcset: String
}
type TopicNode implements Node { type TopicNode implements Node {
"""Der Seitentitel, der öffentlich angezeigt werden soll""" """Der Seitentitel, der öffentlich angezeigt werden soll"""
title: String! title: String!
@ -564,7 +576,7 @@ type SnapshotNode implements Node {
hiddenObjectives(offset: Int, before: String, after: String, first: Int, last: Int, text: String): ObjectiveNodeConnection! hiddenObjectives(offset: Int, before: String, after: String, first: Int, last: Int, text: String): ObjectiveNodeConnection!
title: String title: String
metaTitle: String metaTitle: String
heroImage: String heroImage: WagtailImageNode
changes: SnapshotChangesNode changes: SnapshotChangesNode
mine: Boolean mine: Boolean
} }
@ -740,12 +752,13 @@ type ProjectNode implements Node {
slug: String! slug: String!
objectives: String! objectives: String!
appearance: String! appearance: String!
student: PublicUserNode student: PublicUserNode!
final: Boolean! final: Boolean!
schoolClass: SchoolClassNode schoolClass: SchoolClassNode
entries: [ProjectEntryNode] entries: [ProjectEntryNode]
pk: Int pk: Int
entriesCount: Int entriesCount: Int
owner: PublicUserNode
} }
type ProjectEntryNode implements Node { type ProjectEntryNode implements Node {

View File

@ -22,6 +22,12 @@ class RecentModuleFilter(FilterSet):
order_by = OrderingFilter(fields=(("recent_modules__visited", "visited"),)) order_by = OrderingFilter(fields=(("recent_modules__visited", "visited"),))
def get_resized_avatar_url(avatar_url, size=120):
# resize the avatar to 120px if it is on ucarecdn.com
if "ucarecdn.com" in avatar_url:
return f"{avatar_url}-/resize/{size}/"
return avatar_url
class SchoolClassNode(DjangoObjectType): class SchoolClassNode(DjangoObjectType):
pk = graphene.Int() pk = graphene.Int()
members = graphene.List("users.schema.ClassMemberNode") members = graphene.List("users.schema.ClassMemberNode")
@ -109,6 +115,9 @@ class PrivateUserNode(DjangoObjectType):
def resolve_pk(self, info, **kwargs): def resolve_pk(self, info, **kwargs):
return self.id return self.id
def resolve_avatar_url(self, info, **kwargs):
return get_resized_avatar_url(self.avatar_url, size=240)
def resolve_permissions(self, info): def resolve_permissions(self, info):
return self.get_all_permissions() return self.get_all_permissions()
@ -164,6 +173,10 @@ class PublicUserNode(DjangoObjectType):
only_fields = ["full_name", "first_name", "last_name", "avatar_url"] only_fields = ["full_name", "first_name", "last_name", "avatar_url"]
interfaces = (relay.Node,) interfaces = (relay.Node,)
def resolve_avatar_url(self, info, **kwargs):
return get_resized_avatar_url(self.avatar_url)
@staticmethod @staticmethod
def resolve_is_me(parent: User, info, **kwargs): def resolve_is_me(parent: User, info, **kwargs):
return info.context.user.pk == parent.pk return info.context.user.pk == parent.pk