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
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
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 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 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 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,
@ -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.
*/
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.
*/

File diff suppressed because one or more lines are too long

View File

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

View File

@ -1,8 +1,9 @@
<template>
<img
:src="value.url"
alt=""
:srcset="srcset"
class="image-block"
alt=""
@click="openFullscreen"
/>
</template>
@ -10,6 +11,19 @@
<script>
export default {
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: {
openFullscreen() {
this.$store.dispatch('showFullscreenImage', this.value.url);

View File

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

View File

@ -3,10 +3,13 @@
:to="moduleLink"
:class="['module-teaser', { 'module-teaser--small': !teaser }]"
>
<div
:style="{ backgroundImage: 'url(' + heroImage + ')' }"
<wagtail-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__content">
<h3 class="module-teaser__content-meta-title">{{ metaTitle }}</h3>
@ -33,6 +36,7 @@
import Pill from '@/components/ui/Pill.vue';
import { ModuleCategoryNode, ModuleLevelNode } from '@/__generated__/graphql';
import { computed } from '@vue/reactivity';
import WagtailImage from '@/components/ui/WagtailImage.vue';
export interface Props {
metaTitle: string;
@ -102,10 +106,7 @@ const moduleLink = computed(() => {
width: 100%;
max-height: 150px;
height: 150px;
background-position: center;
background-size: 100% auto;
background-repeat: no-repeat;
// prevent image from shrinking
//prevent image from shrinking
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 "./wagtailImageParts.gql"
fragment ModuleLegacyParts on ModuleNode {
id
title
@ -6,8 +8,10 @@ fragment ModuleLegacyParts on ModuleNode {
teaser
intro
slug
heroImage
heroSource
heroImage {
...WagtailImageParts
}
solutionsEnabled
highlights {
...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
title
metaTitle
heroImage
heroImage {
id
src
srcset
alt
}
created
changes {
newContentBlocks

View File

@ -90,6 +90,19 @@ graphql(`
}
}
`);
graphql(`
fragment WagtailImageParts on WagtailImageNode {
id
src
alt
width
height
title
srcset
}
`);
graphql(`
fragment ModuleParts on ModuleNode {
id
@ -98,7 +111,7 @@ graphql(`
teaser
intro
slug
heroImage
heroImage { ...WagtailImageParts }
heroSource
solutionsEnabled
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
# Taken from https://github.com/patrick91/wagtail-ql/blob/master/backend/graphene_utils/converter.py and slightly adjusted
import logging
from wagtail.images.views.serve import generate_image_url
from graphene.types import Scalar
from graphene_django.converter import convert_django_field
from graphql_relay import to_global_id
from wagtail.fields import StreamField
from wagtail.documents.models import Document
from wagtail.images.models import Image
from assignments.models import Assignment
@ -14,6 +13,7 @@ from basicknowledge.models import BasicKnowledge
from books.models import CustomDocument
from surveys.models import Survey
logger = logging.getLogger(__name__)
@ -42,17 +42,35 @@ def get_document_json(document_id):
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):
for data in raw_data:
if isinstance(data, dict):
_type = data['type']
if _type == 'image_block':
_value = data['value']
value = {
# 'value': _value,
# 'id': d['id'],
'path': Image.objects.get(id=_value).file.url
}
value = get_wagtail_image_dict(_value)
data['value'] = value
if _type == 'assignment':
_value = data['value']

View File

@ -1,17 +1,13 @@
import graphene
from graphene import relay
from wagtail.images.models import Image
from api.graphene_wagtail import generate_image_url
class ModuleInterface(relay.Node):
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')
@staticmethod
def resolve_pk(parent, info, **kwargs):
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 .snapshot 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_django import DjangoObjectType
from graphene_django.filter import DjangoFilterConnectionField
from notes.models import Highlight, ModuleBookmark
from objectives.schema import ObjectiveGroupNode
from surveys.models import Answer

View File

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

View File

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

View File

@ -1,12 +1,13 @@
from django.conf import settings
from django.urls import path, re_path, include
from django.conf.urls.static import static
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 wagtail.admin import urls as wagtailadmin_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.images.views.serve import ServeView
from wagtailautocomplete.urls.admin import urlpatterns as autocomplete_admin_urls
from core import views
@ -17,12 +18,17 @@ urlpatterns = [
re_path(r"^guru/", admin.site.urls),
re_path(r"^statistics/", include("statistics.urls", namespace="statistics")),
# 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/pages/(\d+)/$", override_wagtailadmin_explore_default_ordering),
re_path(r"^cms/", include(wagtailadmin_urls)),
re_path(r"^documents/", include(wagtaildocs_urls)),
# graphql backend
re_path(r"^api/", include("api.urls", namespace="api")),
# favicon
re_path(
r"^favicon\.ico$",

View File

@ -1,9 +1,9 @@
import requests
from core.logger import get_logger
from django.conf import settings
from django.contrib.auth.mixins import LoginRequiredMixin
from django.http import HttpResponse
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.views.decorators.csrf import ensure_csrf_cookie
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 wagtail.admin.views.pages import listing
from core.logger import get_logger
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 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 ENABLE_SILKY=False
export SECRET_KEY=FOOBAR
export USE_AWS=False
export USE_AWS=True
export WAGTAILADMIN_BASE_URL=/
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 APP_FLAVOR=my-kv

View File

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

View File

@ -22,6 +22,12 @@ class RecentModuleFilter(FilterSet):
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):
pk = graphene.Int()
members = graphene.List("users.schema.ClassMemberNode")
@ -109,6 +115,9 @@ class PrivateUserNode(DjangoObjectType):
def resolve_pk(self, info, **kwargs):
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):
return self.get_all_permissions()
@ -164,6 +173,10 @@ class PublicUserNode(DjangoObjectType):
only_fields = ["full_name", "first_name", "last_name", "avatar_url"]
interfaces = (relay.Node,)
def resolve_avatar_url(self, info, **kwargs):
return get_resized_avatar_url(self.avatar_url)
@staticmethod
def resolve_is_me(parent: User, info, **kwargs):
return info.context.user.pk == parent.pk