Merge branch 'develop' into feature/VBV-621-teilnehmer-profil

This commit is contained in:
Reto Aebersold 2024-01-18 08:32:31 +01:00
commit b6ac2ac4b3
15 changed files with 334 additions and 138 deletions

View File

@ -6,6 +6,7 @@ import { useUserStore } from "@/stores/user";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { useTranslation } from "i18next-vue"; import { useTranslation } from "i18next-vue";
import { profileNextRoute, useEntities } from "@/services/onboarding"; import { profileNextRoute, useEntities } from "@/services/onboarding";
import AvatarImage from "@/components/ui/AvatarImage.vue";
const { t } = useTranslation(); const { t } = useTranslation();
@ -36,17 +37,23 @@ const validOrganisation = computed(() => {
return selectedOrganisation.value.id !== 0; return selectedOrganisation.value.id !== 0;
}); });
/* TODO: We do this later (not in the first release) const avatarError = ref(false);
const { const avatarLoading = ref(false);
upload: avatarUpload,
loading: avatarLoading,
error: avatarError,
fileInfo: avatarFileInfo,
} = useFileUpload();
watch(avatarFileInfo, (info) => { async function avatarUpload(e: Event) {
console.log("fileInfo changed", info); const { files } = e.target as HTMLInputElement;
})*/ if (!files?.length) return;
avatarLoading.value = true;
avatarError.value = false;
try {
await user.setUserAvatar(files[0]);
} catch (e) {
avatarError.value = true;
} finally {
avatarLoading.value = false;
}
}
watch(selectedOrganisation, async (organisation) => { watch(selectedOrganisation, async (organisation) => {
await user.setUserOrganisation(organisation.id); await user.setUserOrganisation(organisation.id);
@ -74,7 +81,6 @@ const nextRoute = computed(() => {
<ItDropdownSelect v-model="selectedOrganisation" :items="organisations" /> <ItDropdownSelect v-model="selectedOrganisation" :items="organisations" />
<!--- TODO: We do this later (not in the first release)
<div class="mt-16 flex flex-col justify-between gap-12 lg:flex-row lg:gap-24"> <div class="mt-16 flex flex-col justify-between gap-12 lg:flex-row lg:gap-24">
<div> <div>
<h3 class="mb-3">{{ $t("a.Profilbild") }}</h3> <h3 class="mb-3">{{ $t("a.Profilbild") }}</h3>
@ -103,7 +109,6 @@ const nextRoute = computed(() => {
</div> </div>
<AvatarImage :loading="avatarLoading" :image-url="user.avatar_url" /> <AvatarImage :loading="avatarLoading" :image-url="user.avatar_url" />
</div> </div>
-->
</template> </template>
<template #footer> <template #footer>

View File

@ -24,27 +24,30 @@ export async function uploadFile(fileData: FileData, file: File) {
if (fileData.fields) { if (fileData.fields) {
return s3Upload(fileData, file); return s3Upload(fileData, file);
} else { } else {
return directUpload(fileData, file); return directUpload(fileData.url, file);
} }
} }
function directUpload(fileData: FileData, file: File) { export function directUpload(url: string, file: File) {
const formData = new FormData(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
const headers = { const headers: HeadersInit = {
Accept: "application/json", Accept: "application/json",
} as HeadersInit; };
const csrfToken = getCookieValue("csrftoken");
if (csrfToken) {
headers["X-CSRFToken"] = csrfToken;
}
const options = { const options = {
method: "POST", method: "POST",
headers: headers, headers: headers,
body: formData, body: formData,
}; };
// @ts-ignore
options.headers["X-CSRFToken"] = getCookieValue("csrftoken");
return handleUpload(fileData.url, options); return handleUpload(url, options);
} }
function s3Upload(fileData: FileData, file: File) { function s3Upload(fileData: FileData, file: File) {

View File

@ -2,6 +2,7 @@ import log from "loglevel";
import { bustItGetCache, itGetCached, itPost } from "@/fetchHelpers"; import { bustItGetCache, itGetCached, itPost } from "@/fetchHelpers";
import { setI18nLanguage } from "@/i18nextWrapper"; import { setI18nLanguage } from "@/i18nextWrapper";
import { directUpload } from "@/services/files";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
@ -150,5 +151,9 @@ export const useUserStore = defineStore({
this.$state.organisation = organisation; this.$state.organisation = organisation;
await itPost("/api/core/me/", { organisation }, { method: "PUT" }); await itPost("/api/core/me/", { organisation }, { method: "PUT" });
}, },
async setUserAvatar(file: File) {
const r = await directUpload("/api/core/avatar/", file);
this.$state.avatar_url = r.url;
},
}, },
}); });

View File

@ -12,7 +12,7 @@ from django_ratelimit.exceptions import Ratelimited
from graphene_django.views import GraphQLView from graphene_django.views import GraphQLView
from vbv_lernwelt.api.directory import list_entities from vbv_lernwelt.api.directory import list_entities
from vbv_lernwelt.api.user import get_cockpit_type, get_profile, me_user_view from vbv_lernwelt.api.user import get_cockpit_type, get_profile, me_user_view, post_avatar
from vbv_lernwelt.assignment.views import request_assignment_completion_status from vbv_lernwelt.assignment.views import request_assignment_completion_status
from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt
from vbv_lernwelt.core.schema import schema from vbv_lernwelt.core.schema import schema
@ -54,6 +54,7 @@ from vbv_lernwelt.importer.views import (
coursesessions_trainers_import, coursesessions_trainers_import,
t2l_sync, t2l_sync,
) )
from vbv_lernwelt.media_files.views import user_image
from vbv_lernwelt.notify.views import email_notification_settings from vbv_lernwelt.notify.views import email_notification_settings
from wagtail import urls as wagtail_urls from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls from wagtail.admin import urls as wagtailadmin_urls
@ -99,6 +100,7 @@ urlpatterns = [
# user management # user management
path("sso/", include("vbv_lernwelt.sso.urls")), path("sso/", include("vbv_lernwelt.sso.urls")),
re_path(r'api/core/me/$', me_user_view, name='me_user_view'), re_path(r'api/core/me/$', me_user_view, name='me_user_view'),
re_path(r'api/core/avatar/$', post_avatar, name='post_avatar'),
re_path(r'api/core/entities/$', list_entities, name='list_entities'), re_path(r'api/core/entities/$', list_entities, name='list_entities'),
path(r'api/core/profile/<signed_int:course_session_id>/<uuid:user_id>', get_profile, name='get_profile_view'), path(r'api/core/profile/<signed_int:course_session_id>/<uuid:user_id>', get_profile, name='get_profile_view'),
@ -141,6 +143,8 @@ urlpatterns = [
name="request_assignment_completion_status"), name="request_assignment_completion_status"),
# documents # documents
path("api/core/userimage/<int:image_id>", user_image, name="user_image"),
# TODO: remfactor to files app # TODO: remfactor to files app
path(r'api/core/document/start/', document_upload_start, path(r'api/core/document/start/', document_upload_start,
name='file_upload_start'), name='file_upload_start'),

View File

@ -9,6 +9,7 @@ from vbv_lernwelt.course.models import Course, CourseSessionUser
from vbv_lernwelt.course_session_group.models import CourseSessionGroup from vbv_lernwelt.course_session_group.models import CourseSessionGroup
from vbv_lernwelt.iam.permissions import can_view_profile from vbv_lernwelt.iam.permissions import can_view_profile
from vbv_lernwelt.learning_mentor.models import LearningMentor from vbv_lernwelt.learning_mentor.models import LearningMentor
from vbv_lernwelt.media_files.models import UserImage
@api_view(["GET", "PUT"]) @api_view(["GET", "PUT"])
@ -72,3 +73,17 @@ def get_profile(request, course_session_id: int, user_id: str):
return Response(status=403) return Response(status=403)
return Response(UserSerializer(course_session_user.user).data) return Response(UserSerializer(course_session_user.user).data)
@api_view(["POST"])
@permission_classes([IsAuthenticated])
def post_avatar(request):
if "file" not in request.FILES:
return Response(status=400)
request.user.avatar = UserImage.objects.create(
file=request.FILES["file"],
)
request.user.save()
return Response({"url": request.user.avatar_url})

View File

@ -43,7 +43,7 @@ class UserAdmin(auth_admin.UserAdmin):
}, },
), ),
(_("Important dates"), {"fields": ("last_login", "date_joined")}), (_("Important dates"), {"fields": ("last_login", "date_joined")}),
(_("Profile"), {"fields": ("organisation", "language")}), (_("Profile"), {"fields": ("organisation", "language", "avatar")}),
(_("Additional data"), {"fields": ("additional_json_data",)}), (_("Additional data"), {"fields": ("additional_json_data",)}),
) )
list_display = [ list_display = [

View File

@ -1,7 +1,11 @@
from django.conf import settings
from django.contrib.auth.hashers import make_password from django.contrib.auth.hashers import make_password
from django.contrib.auth.models import Group, Permission from django.contrib.auth.models import Group, Permission
from django.core.files import File
from environs import Env from environs import Env
from vbv_lernwelt.media_files.models import UserImage
env = Env() env = Env()
env.read_env() env.read_env()
@ -24,52 +28,54 @@ default_users = [
"email": "student", "email": "student",
"first_name": "Student", "first_name": "Student",
"last_name": "Meier", "last_name": "Meier",
"avatar_url": "/static/avatars/avatar_iterativ.png", "avatar_image": "avatar_iterativ.png",
}, },
{ {
"email": "daniel.egger@iterativ.ch", "email": "daniel.egger@iterativ.ch",
"first_name": "Daniel", "first_name": "Daniel",
"last_name": "Egger", "last_name": "Egger",
"avatar_url": "/static/avatars/avatar_iterativ.png", "avatar_image": "avatar_iterativ.png",
}, },
{ {
"email": "axel.manderbach@lernetz.ch", "email": "axel.manderbach@lernetz.ch",
"first_name": "Axel", "first_name": "Axel",
"last_name": "Manderbach", "last_name": "Manderbach",
"avatar_url": "/static/avatars/avatar_axel.jpg", "avatar_image": "avatar_axel.jpg",
}, },
{ {
"email": "christoph.bosshard@vbv-afa.ch", "email": "christoph.bosshard@vbv-afa.ch",
"first_name": "Christoph", "first_name": "Christoph",
"last_name": "Bosshard", "last_name": "Bosshard",
"avatar_url": "/static/avatars/avatar_christoph.png", "avatar_image": "avatar_christoph.png",
"password": "myvbv1234", "password": "myvbv1234",
}, },
{ {
"email": "alexandra.vangelista@lernetz.ch", "email": "alexandra.vangelista@lernetz.ch",
"first_name": "Alexandra", "first_name": "Alexandra",
"last_name": "Vangelista", "last_name": "Vangelista",
"avatar_url": "/static/avatars/avatar_alexandra.png", "avatar_image": "avatar_alexandra.png",
"password": "myvbv1234", "password": "myvbv1234",
}, },
{ {
"email": "chantal.rosenberg@vbv-afa.ch", "email": "chantal.rosenberg@vbv-afa.ch",
"first_name": "Chantal", "first_name": "Chantal",
"last_name": "Rosenberg", "last_name": "Rosenberg",
"avatar_url": "/static/avatars/avatar_chantal.png", "avatar_image": "avatar_chantal.png",
"password": "myvbv1234", "password": "myvbv1234",
}, },
{ {
"email": "bianca.muster@eiger-versicherungen.ch", "email": "bianca.muster@eiger-versicherungen.ch",
"first_name": "Bianca", "first_name": "Bianca",
"last_name": "Muster", "last_name": "Muster",
"avatar_url": "/static/avatars/avatar_bianca.png", "avatar_image": "avatar_bianca.png",
"password": "myvbv1234", "password": "myvbv1234",
}, },
] ]
AVATAR_DIR = settings.APPS_DIR / "static" / "avatars"
def create_default_users(default_password="test"):
def create_default_users(default_password="test", set_avatar=False):
admin_group, created = Group.objects.get_or_create(name="admin_group") admin_group, created = Group.objects.get_or_create(name="admin_group")
_content_creator_grop, _created = Group.objects.get_or_create( _content_creator_grop, _created = Group.objects.get_or_create(
name="content_creator_grop" name="content_creator_grop"
@ -81,9 +87,9 @@ def create_default_users(default_password="test"):
email, email,
first_name, first_name,
last_name, last_name,
avatar_url,
language, language,
password, password,
avatar_image: str = None,
): ):
user, _ = User.objects.get_or_create( user, _ = User.objects.get_or_create(
id=_id, id=_id,
@ -92,25 +98,33 @@ def create_default_users(default_password="test"):
language=language, language=language,
first_name=first_name, first_name=first_name,
last_name=last_name, last_name=last_name,
avatar_url=avatar_url,
password=make_password(password), password=make_password(password),
) )
if avatar_image and set_avatar:
with open(AVATAR_DIR / avatar_image, "rb") as f:
image, _ = UserImage.objects.get_or_create(
file=File(f),
)
user.avatar = image
user.save()
return user return user
def _create_student_user( def _create_student_user(
email, email,
first_name, first_name,
last_name, last_name,
avatar_url="",
password=default_password, password=default_password,
language="de", language="de",
avatar_image=None,
id=None, id=None,
): ):
student_user = _create_user( student_user = _create_user(
email=email, email=email,
first_name=first_name, first_name=first_name,
last_name=last_name, last_name=last_name,
avatar_url=avatar_url, avatar_image=avatar_image,
language=language, language=language,
password=password, password=password,
_id=id, _id=id,
@ -120,13 +134,18 @@ def create_default_users(default_password="test"):
student_user.save() student_user.save()
def _create_admin_user( def _create_admin_user(
email, first_name, last_name, avatar_url="", id=None, password=default_password email,
first_name,
last_name,
avatar_image=None,
id=None,
password=default_password,
): ):
admin_user = _create_user( admin_user = _create_user(
email=email, email=email,
first_name=first_name, first_name=first_name,
last_name=last_name, last_name=last_name,
avatar_url=avatar_url, avatar_image=avatar_image,
password=password, password=password,
language="de", language="de",
_id=id, _id=id,
@ -145,7 +164,7 @@ def create_default_users(default_password="test"):
email=email, email=email,
first_name=first_name, first_name=first_name,
last_name=last_name, last_name=last_name,
avatar_url="", avatar_image=None,
language="de", language="de",
password=password, password=password,
) )
@ -158,7 +177,7 @@ def create_default_users(default_password="test"):
email="info@iterativ.ch", email="info@iterativ.ch",
first_name="Info", first_name="Info",
last_name="Iterativ", last_name="Iterativ",
avatar_url="/static/avatars/avatar_iterativ.png", avatar_image="avatar_iterativ.png",
password=env("IT_DEFAULT_ADMIN_PASSWORD", default_password), password=env("IT_DEFAULT_ADMIN_PASSWORD", default_password),
) )
@ -166,7 +185,7 @@ def create_default_users(default_password="test"):
email="admin", email="admin",
first_name="Peter", first_name="Peter",
last_name="Adminson", last_name="Adminson",
avatar_url="/static/avatars/avatar_iterativ.png", avatar_image="avatar_iterativ.png",
id=ADMIN_USER_ID, id=ADMIN_USER_ID,
password=env("IT_DEFAULT_ADMIN_PASSWORD", default_password), password=env("IT_DEFAULT_ADMIN_PASSWORD", default_password),
) )
@ -183,7 +202,7 @@ def create_default_users(default_password="test"):
email="expert-vv.expert2@eiger-versicherungen.ch", email="expert-vv.expert2@eiger-versicherungen.ch",
first_name="Christa", first_name="Christa",
last_name="von Allmen", last_name="von Allmen",
avatar_url="/static/avatars/uk1.patrizia.huggel.jpg", avatar_image="uk1.patrizia.huggel.jpg",
) )
_create_student_user( _create_student_user(
email="expert-vv.expert3@eiger-versicherungen.ch", email="expert-vv.expert3@eiger-versicherungen.ch",
@ -199,53 +218,53 @@ def create_default_users(default_password="test"):
email="patrizia.huggel@eiger-versicherungen.ch", email="patrizia.huggel@eiger-versicherungen.ch",
first_name="Patrizia", first_name="Patrizia",
last_name="Huggel", last_name="Huggel",
avatar_url="/static/avatars/uk1.patrizia.huggel.jpg", avatar_image="uk1.patrizia.huggel.jpg",
password="myvbv1234", password="myvbv1234",
) )
_create_student_user( _create_student_user(
email="andreas.feuz@eiger-versicherungen.ch", email="andreas.feuz@eiger-versicherungen.ch",
first_name="Andreas", first_name="Andreas",
last_name="Feuz", last_name="Feuz",
avatar_url="/static/avatars/uk1.daniel.tanaka.jpg", avatar_image="uk1.daniel.tanaka.jpg",
password="myvbv1234", password="myvbv1234",
) )
_create_student_user( _create_student_user(
email="daniel.tanaka@eiger-versicherung.ch", email="daniel.tanaka@eiger-versicherung.ch",
first_name="Daniel", first_name="Daniel",
last_name="Tanaka", last_name="Tanaka",
avatar_url="/static/avatars/uk1.daniel.tanaka.jpg", avatar_image="uk1.daniel.tanaka.jpg",
) )
_create_student_user( _create_student_user(
email="maria.spini@eiger-versicherung.ch", email="maria.spini@eiger-versicherung.ch",
first_name="Maria", first_name="Maria",
last_name="Spini", last_name="Spini",
avatar_url="/static/avatars/uk1.maria.spini.jpg", avatar_image="uk1.maria.spini.jpg",
) )
_create_student_user( _create_student_user(
email="christian.koller@eiger-versicherung.ch", email="christian.koller@eiger-versicherung.ch",
first_name="Christian", first_name="Christian",
last_name="Koller", last_name="Koller",
avatar_url="/static/avatars/uk1.christian.koller.jpg", avatar_image="uk1.christian.koller.jpg",
) )
_create_student_user( _create_student_user(
email="michael.meier@example.com", email="michael.meier@example.com",
first_name="Michael", first_name="Michael",
last_name="Meier", last_name="Meier",
avatar_url="/static/avatars/uk1.michael.meier.jpg", avatar_image="uk1.michael.meier.jpg",
password="myvbv1234", password="myvbv1234",
) )
_create_student_user( _create_student_user(
email="lina.egger@example.com", email="lina.egger@example.com",
first_name="Lina", first_name="Lina",
last_name="Egger", last_name="Egger",
avatar_url="/static/avatars/uk1.lina.egger.jpg", avatar_image="uk1.lina.egger.jpg",
password="myvbv1234", password="myvbv1234",
) )
_create_student_user( _create_student_user(
email="evelyn.schmid@example.com", email="evelyn.schmid@example.com",
first_name="Evelyn", first_name="Evelyn",
last_name="Schmid", last_name="Schmid",
avatar_url="/static/avatars/uk1.evelyn.schmid.jpg", avatar_image="uk1.evelyn.schmid.jpg",
) )
_create_student_user( _create_student_user(
@ -272,7 +291,7 @@ def create_default_users(default_password="test"):
email="luca.dupont@assurance.ch", email="luca.dupont@assurance.ch",
first_name="Luca", first_name="Luca",
last_name="Dupont", last_name="Dupont",
avatar_url="/static/avatars/uk1.michael.meier.jpg", avatar_image="uk1.michael.meier.jpg",
password="myafa1234", password="myafa1234",
language="fr", language="fr",
) )
@ -280,7 +299,7 @@ def create_default_users(default_password="test"):
email="patrick.muster@eiger-versicherungen.ch", email="patrick.muster@eiger-versicherungen.ch",
first_name="Patrick", first_name="Patrick",
last_name="Muster", last_name="Muster",
avatar_url="/static/avatars/uk1.michael.meier.jpg", avatar_image="uk1.michael.meier.jpg",
password="myvbv1234", password="myvbv1234",
language="de", language="de",
) )
@ -288,7 +307,7 @@ def create_default_users(default_password="test"):
email="geraldine.kolly@assurance.ch", email="geraldine.kolly@assurance.ch",
first_name="Géraldine", first_name="Géraldine",
last_name="Kolly", last_name="Kolly",
avatar_url="/static/avatars/uk1.patrizia.huggel.jpg", avatar_image="uk1.patrizia.huggel.jpg",
password="myafa1234", password="myafa1234",
language="fr", language="fr",
) )
@ -299,35 +318,35 @@ def create_default_users(default_password="test"):
email="test-trainer1@example.com", email="test-trainer1@example.com",
first_name="Test", first_name="Test",
last_name="Trainer1", last_name="Trainer1",
avatar_url="/static/avatars/uk1.patrizia.huggel.jpg", avatar_image="uk1.patrizia.huggel.jpg",
) )
_create_student_user( _create_student_user(
id=TEST_TRAINER2_USER_ID, id=TEST_TRAINER2_USER_ID,
email="test-trainer2@example.com", email="test-trainer2@example.com",
first_name="Test", first_name="Test",
last_name="Trainer2", last_name="Trainer2",
avatar_url="/static/avatars/uk1.christian.koller.jpg", avatar_image="uk1.christian.koller.jpg",
) )
_create_student_user( _create_student_user(
id=TEST_STUDENT1_USER_ID, id=TEST_STUDENT1_USER_ID,
email="test-student1@example.com", email="test-student1@example.com",
first_name="Test", first_name="Test",
last_name="Student1", last_name="Student1",
avatar_url="/static/avatars/uk1.michael.meier.jpg", avatar_image="uk1.michael.meier.jpg",
) )
_create_student_user( _create_student_user(
id=TEST_STUDENT2_USER_ID, id=TEST_STUDENT2_USER_ID,
email="test-student2@example.com", email="test-student2@example.com",
first_name="Test", first_name="Test",
last_name="Student2", last_name="Student2",
avatar_url="/static/avatars/uk1.lina.egger.jpg", avatar_image="uk1.lina.egger.jpg",
) )
_create_student_user( _create_student_user(
id=TEST_STUDENT3_USER_ID, id=TEST_STUDENT3_USER_ID,
email="test-student3@example.com", email="test-student3@example.com",
first_name="Test", first_name="Test",
last_name="Student3", last_name="Student3",
avatar_url="/static/avatars/uk1.christian.koller.jpg", avatar_image="uk1.christian.koller.jpg",
) )
_create_staff_user( _create_staff_user(
email="matthias.wirth@vbv-afa.ch", email="matthias.wirth@vbv-afa.ch",
@ -341,7 +360,6 @@ def create_default_users(default_password="test"):
last_name="Regionalleiter", last_name="Regionalleiter",
password=default_password, password=default_password,
language="de", language="de",
avatar_url="",
) )
_create_user( _create_user(
_id=TEST_MENTOR1_USER_ID, _id=TEST_MENTOR1_USER_ID,
@ -350,7 +368,6 @@ def create_default_users(default_password="test"):
last_name="Mentor", last_name="Mentor",
password=default_password, password=default_password,
language="de", language="de",
avatar_url="",
) )

View File

@ -1,5 +1,6 @@
import json import json
from graphene import String
from graphene.types.generic import GenericScalar from graphene.types.generic import GenericScalar
from graphene_django import DjangoObjectType from graphene_django import DjangoObjectType
@ -7,6 +8,8 @@ from vbv_lernwelt.core.models import User
class UserObjectType(DjangoObjectType): class UserObjectType(DjangoObjectType):
avatar_url = String(source="avatar_url")
class Meta: class Meta:
model = User model = User
fields = ( fields = (

View File

@ -6,4 +6,4 @@ from vbv_lernwelt.core.create_default_users import create_default_users
@click.command() @click.command()
def command(): def command():
print("Creating default users.") print("Creating default users.")
create_default_users() create_default_users(set_avatar=True)

View File

@ -0,0 +1,30 @@
import django.db.models.deletion
from django.db import migrations, models
from vbv_lernwelt.core.model_utils import migrate_avatars
class Migration(migrations.Migration):
dependencies = [
("media_files", "0001_initial"),
("core", "0003_organisations"),
]
operations = [
migrations.AddField(
model_name="user",
name="avatar",
field=models.ForeignKey(
blank=True,
help_text="Avatar image for the user",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to="media_files.userimage",
),
),
migrations.RunPython(migrate_avatars),
migrations.RemoveField(
model_name="user",
name="avatar_url",
),
]

View File

@ -1,3 +1,5 @@
from django.conf import settings
from django.core.files import File
from wagtail.models import Page from wagtail.models import Page
@ -144,3 +146,39 @@ def remove_organisations(apps=None, schema_editor=None):
Organisation.objects.filter( Organisation.objects.filter(
organisation_id=org_id, organisation_id=org_id,
).delete() ).delete()
def migrate_avatars(apps=None, schema_editor=None):
# pylint: disable=import-outside-toplevel
if apps is None:
from vbv_lernwelt.core.models import User
from vbv_lernwelt.media_files.models import UserImage
else:
User = apps.get_model("core", "User")
UserImage = apps.get_model("media_files", "UserImage")
# Models created by Django migration don't contain methods of the parent model.
# We need to add them manually.
from wagtail.images.models import AbstractImage
UserImage.get_upload_to = AbstractImage.get_upload_to
avatar_dir = settings.APPS_DIR / "static" / "avatars"
for user in User.objects.all().exclude(
avatar_url="/static/avatars/myvbv-default-avatar.png"
):
if not user.avatar_url:
continue
avatar_file = user.avatar_url.split("/")[-1]
try:
with open(avatar_dir / avatar_file, "rb") as f:
image = UserImage.objects.create(
file=File(f),
)
user.avatar = image
user.save()
except FileNotFoundError:
pass

View File

@ -3,6 +3,7 @@ import uuid
from django.contrib.auth.models import AbstractUser from django.contrib.auth.models import AbstractUser
from django.db import models from django.db import models
from django.db.models import JSONField from django.db.models import JSONField
from django.urls import reverse
class Organisation(models.Model): class Organisation(models.Model):
@ -34,9 +35,14 @@ class User(AbstractUser):
id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
avatar_url = models.CharField( avatar = models.ForeignKey(
max_length=254, blank=True, default="/static/avatars/myvbv-default-avatar.png" "media_files.UserImage",
null=True,
blank=True,
on_delete=models.SET_NULL,
help_text="Avatar image for the user",
) )
email = models.EmailField("email address", unique=True) email = models.EmailField("email address", unique=True)
sso_id = models.UUIDField( sso_id = models.UUIDField(
"SSO subscriber ID", unique=True, null=True, blank=True, default=None "SSO subscriber ID", unique=True, null=True, blank=True, default=None
@ -48,6 +54,16 @@ class User(AbstractUser):
Organisation, on_delete=models.SET_NULL, null=True, blank=True Organisation, on_delete=models.SET_NULL, null=True, blank=True
) )
@property
def avatar_url(self):
if self.avatar:
filter_spec = "fill-400x400"
self.avatar.get_rendition(filter_spec)
url = reverse("user_image", kwargs={"image_id": self.avatar.id})
return f"{url}?filter={filter_spec}"
else:
return "/static/avatars/myvbv-default-avatar.png"
class SecurityRequestResponseLog(models.Model): class SecurityRequestResponseLog(models.Model):
label = models.CharField(max_length=255, blank=True, default="") label = models.CharField(max_length=255, blank=True, default="")

View File

@ -0,0 +1,70 @@
import os
from pathlib import Path
from unittest import skipIf
from django.core.files import File
from django.test import RequestFactory, TestCase
from django.urls import reverse
from vbv_lernwelt.course.creators.test_utils import create_user
from vbv_lernwelt.media_files.models import UserImage
TEST_IMAGE = Path(__file__).parent / "test_images" / "user1_profile.jpg"
@skipIf(
os.environ.get("ENABLE_S3_STORAGE_UNIT_TESTS") is None,
"Only enable tests by setting ENABLE_S3_STORAGE_UNIT_TESTS=1",
)
class UserImageViewTest(TestCase):
def setUp(self):
self.factory = RequestFactory()
self.user = create_user("supervisor")
with open(TEST_IMAGE, "rb") as f:
self.user_image, _ = UserImage.objects.get_or_create(
file=File(f, name=TEST_IMAGE.name),
)
def test_image(self):
# GIVEN
self.client.force_login(self.user)
# WHEN
response = self._get_image(self.user_image.id, "fill-300x150")
# THEN
self.assertEqual(response.status_code, 200)
self.assertEqual(response["Content-Type"], "image/jpeg")
def test_invalid_id(self):
# GIVEN
image_id = 123456789
self.client.force_login(self.user)
# WHEN
response = self._get_image(image_id)
# THEN
self.assertEqual(response.status_code, 404)
def test_invalid_filter(self):
# GIVEN
self.client.force_login(self.user)
filter_spec = "invalid-filter"
# WHEN
response = self._get_image(self.user_image.id, filter_spec)
# THEN
self.assertEqual(response.status_code, 400)
self.assertEqual(
response.content, f"Invalid filter spec: {filter_spec}".encode()
)
def _get_image(self, image_id, filter_spec=None):
url = reverse("user_image", kwargs={"image_id": image_id})
if filter_spec:
url += f"?filter={filter_spec}"
return self.client.get(url)

View File

@ -0,0 +1,39 @@
import imghdr
from wsgiref.util import FileWrapper
import structlog
from django.contrib.auth.decorators import login_required
from django.http import HttpResponse, StreamingHttpResponse
from django.shortcuts import get_object_or_404
from wagtail.images.exceptions import InvalidFilterSpecError
from wagtail.images.models import SourceImageIOError
from vbv_lernwelt.media_files.models import UserImage
logger = structlog.get_logger(__name__)
@login_required
def user_image(request, image_id):
image = get_object_or_404(UserImage, id=image_id)
filter_spec = request.GET.get("filter", "original")
try:
rendition = image.get_rendition(filter_spec)
except SourceImageIOError:
return HttpResponse(
"Source image file not found", content_type="text/plain", status=410
)
except InvalidFilterSpecError:
return HttpResponse(
"Invalid filter spec: " + filter_spec,
content_type="text/plain",
status=400,
)
rendition.file.open("rb")
image_format = imghdr.what(rendition.file)
return StreamingHttpResponse(
FileWrapper(rendition.file), content_type="image/" + image_format
)

View File

@ -1,78 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg id="Anzeigebild" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 119.93 99"> <!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<defs> <svg width="100%" height="100%" viewBox="0 0 120 120" version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xml:space="preserve" xmlns:serif="http://www.serif.com/" style="fill-rule:evenodd;clip-rule:evenodd;">
<style> <g id="Artboard1" transform="matrix(1,0,0,1.21111,0,0)">
.cls-1 { <rect x="0" y="0" width="119.93" height="99" style="fill:none;"/>
stroke-width: 2.29px; <g transform="matrix(1,0,0,1,11.64,0.93876)">
} <g transform="matrix(0.805954,0,0,0.670366,0,15.3769)">
<ellipse cx="59.96" cy="64.42" rx="59.96" ry="34.58" style="fill:rgb(237,242,246);"/>
.cls-1, .cls-2 { </g>
stroke: #a66635; <g transform="matrix(0.805954,0,0,0.670366,0,15.3769)">
} <path d="M74.08,46.16C73.64,45.74 72.94,45.76 72.53,46.21C71.3,47.52 69.98,48.8 68.62,50.07C71.35,45.54 73.02,40.58 73.02,36.2C73.02,29.9 69.49,25.83 64.03,25.83C61.53,25.83 58.71,26.69 55.87,28.33C47.37,33.24 40.4,44.09 39.85,53C39.85,53.03 39.83,53.06 39.83,53.09C39.81,53.2 37.88,64.4 29.18,70.46C27.86,70.49 26.64,70.41 25.54,70.18C24.95,70.06 24.37,70.44 24.24,71.04C24.12,71.63 24.5,72.21 25.1,72.34C26.21,72.57 27.41,72.68 28.69,72.68C35.13,72.68 43.62,69.86 52.67,64.63C54.19,63.75 55.7,62.82 57.19,61.84C57.38,61.73 57.57,61.61 57.76,61.49C57.8,61.46 57.85,61.43 57.89,61.41C58.13,61.26 58.36,61.11 58.59,60.95C58.59,60.95 58.61,60.94 58.62,60.93C59.15,60.57 59.67,60.19 60.18,59.79C65.26,56.17 70.05,52.04 74.12,47.73C74.54,47.29 74.52,46.59 74.08,46.18L74.08,46.16ZM43.96,57.17C43.96,51.09 49.79,42.78 56.97,38.64C59.51,37.18 61.93,36.4 63.97,36.4C67.14,36.4 68.88,38.22 68.88,41.52C68.88,44 67.9,46.85 66.26,49.64C66.37,49.06 66.43,48.48 66.43,47.93C66.43,44.64 64.4,42.52 61.26,42.52C59.63,42.52 57.76,43.1 55.87,44.19C50.57,47.25 46.42,53.41 46.42,58.21C46.42,59.88 46.95,61.25 47.88,62.2C47.72,62.18 47.56,62.16 47.4,62.13C45.17,61.63 43.96,59.91 43.96,57.17ZM55.27,60.37C53.93,61.06 52.67,61.42 51.58,61.42C49.64,61.42 48.61,60.31 48.61,58.21C48.61,54.25 52.44,48.7 56.97,46.09C58.53,45.19 60.01,44.72 61.26,44.72C63.2,44.72 64.23,45.83 64.23,47.93C64.23,51.41 61.27,56.12 57.47,58.98C56.92,59.36 56.36,59.72 55.8,60.09C55.62,60.19 55.45,60.28 55.28,60.38L55.27,60.37ZM56.96,30.22C59.46,28.78 61.9,28.01 64.02,28.01C68.28,28.01 70.82,31.06 70.82,36.18C70.82,36.99 70.76,37.82 70.64,38.66C69.71,35.85 67.33,34.19 63.97,34.19C61.54,34.19 58.74,35.07 55.87,36.72C49.03,40.67 43.31,48.24 42.03,54.62C42.03,54.44 42.01,54.26 42.01,54.07C42.01,45.67 48.72,34.97 56.96,30.21L56.96,30.22ZM40.53,58.59C41.62,61.67 43.9,63.72 46.99,64.28C47.42,64.37 47.87,64.43 48.34,64.45C42.87,67.29 37.65,69.19 33.18,69.99C37.03,66.36 39.27,61.95 40.52,58.58L40.53,58.59Z" style="fill:rgb(60,60,59);fill-rule:nonzero;"/>
<path d="M27.8,71.52C44.86,71.97 64.39,56.33 74.36,46.36" style="fill-rule:nonzero;stroke:rgb(125,78,42);stroke-width:1px;"/>
.cls-1, .cls-2, .cls-3 { <path d="M46.7,66.61L51.7,43.79" style="fill:none;fill-rule:nonzero;stroke:rgb(125,78,42);stroke-width:1px;"/>
fill: none; <path d="M66.69,53.27L63.26,39.41" style="fill:none;fill-rule:nonzero;stroke:rgb(125,78,42);stroke-width:1px;"/>
stroke-linecap: round; <path d="M67.18,26.01L51.54,33.89C50.84,34.29 50.62,34.45 50.22,34.8" style="fill-rule:nonzero;stroke:rgb(125,78,42);stroke-width:1px;"/>
stroke-miterlimit: 10; <path d="M67.66,78.58L72.58,56.13" style="fill:none;fill-rule:nonzero;"/>
} <path d="M86.47,66.17L81.41,45.68" style="fill:none;fill-rule:nonzero;"/>
<path d="M47.64,47.78L47.78,46.76C47.98,45.31 48.98,43.77 50.14,43.1C50.18,43.07 50.23,43.05 50.27,43.03L60.64,37.7C60.64,37.7 60.73,37.65 60.77,37.63C61.73,37.08 62.6,35.9 62.95,34.66L71.84,2.47C72.08,1.62 72.68,0.82 73.33,0.44C73.39,0.41 73.45,0.37 73.51,0.35L74.01,0.13C74.39,-0.04 74.72,-0.03 74.97,0.11L95.23,11.88C94.98,11.74 94.65,11.73 94.27,11.9L93.77,12.12C93.71,12.15 93.65,12.18 93.59,12.21C92.94,12.59 92.34,13.39 92.1,14.24L83.22,46.43C82.86,47.67 82,48.85 81.04,49.4C81,49.43 80.95,49.45 80.91,49.47L70.54,54.8C70.54,54.8 70.45,54.85 70.41,54.87C69.25,55.54 68.24,57.08 68.05,58.53L67.91,59.55C67.84,60.08 67.99,60.47 68.26,60.63L48,48.86C47.72,48.7 47.57,48.31 47.65,47.78L47.64,47.78Z" style="fill:rgb(175,200,223);fill-rule:nonzero;"/>
.cls-2, .cls-3 { <path d="M93.59,12.21C92.94,12.59 92.34,13.39 92.1,14.24L83.22,46.43C82.86,47.67 82,48.85 81.04,49.4C81,49.43 80.95,49.45 80.91,49.47L70.54,54.8C70.54,54.8 70.45,54.85 70.41,54.87C69.25,55.54 68.24,57.08 68.05,58.53L67.91,59.55C67.78,60.52 68.38,61 69.17,60.54L84.34,51.78C85.11,51.34 85.8,50.39 86.07,49.4L95.61,13.61C95.97,12.27 95.32,11.43 94.28,11.89L93.78,12.11C93.72,12.14 93.66,12.17 93.6,12.2L93.59,12.21Z" style="fill:rgb(151,179,205);fill-rule:nonzero;"/>
stroke-width: 2.52px; <path d="M68.83,56.45L48.57,44.68C48.16,45.32 47.87,46.05 47.78,46.75L47.64,47.77C47.57,48.3 47.72,48.69 47.99,48.85L68.25,60.62C67.97,60.46 67.82,60.07 67.9,59.54L68.04,58.52C68.14,57.81 68.43,57.08 68.83,56.45Z" style="fill:rgb(125,149,172);fill-rule:nonzero;"/>
} <path d="M61.85,36.69C62.34,36.11 62.74,35.4 62.95,34.65L71.84,2.47C71.94,2.12 72.11,1.79 72.31,1.49L92.57,13.26C92.37,13.57 92.2,13.9 92.1,14.25L83.22,46.44C83.01,47.18 82.61,47.9 82.12,48.48L61.86,36.71L61.85,36.69Z" style="fill:rgb(125,149,172);fill-rule:nonzero;"/>
<path d="M94.96,58.5C94.52,58.08 93.82,58.1 93.41,58.55C92.18,59.86 90.86,61.14 89.5,62.41C92.23,57.88 93.9,52.92 93.9,48.54C93.9,42.24 90.37,38.17 84.91,38.17C82.41,38.17 79.59,39.03 76.75,40.67C68.25,45.58 61.28,56.43 60.73,65.34C60.73,65.37 60.71,65.4 60.71,65.43C60.69,65.54 58.76,76.74 50.06,82.8C48.74,82.83 47.52,82.75 46.42,82.52C45.83,82.4 45.25,82.78 45.12,83.38C45,83.97 45.38,84.55 45.98,84.68C47.09,84.91 48.29,85.02 49.57,85.02C56.01,85.02 64.5,82.2 73.55,76.97C75.07,76.09 76.58,75.16 78.07,74.18C78.26,74.07 78.45,73.95 78.64,73.83C78.68,73.8 78.73,73.77 78.77,73.75C79.01,73.6 79.24,73.45 79.47,73.29C79.47,73.29 79.49,73.28 79.5,73.27C80.03,72.91 80.55,72.53 81.06,72.13C86.14,68.51 90.93,64.38 95,60.07C95.42,59.63 95.4,58.93 94.96,58.52L94.96,58.5ZM64.84,69.51C64.84,63.43 70.67,55.12 77.85,50.98C80.39,49.52 82.81,48.74 84.85,48.74C88.02,48.74 89.76,50.56 89.76,53.86C89.76,56.34 88.78,59.19 87.14,61.98C87.25,61.4 87.31,60.82 87.31,60.27C87.31,56.98 85.28,54.86 82.14,54.86C80.51,54.86 78.64,55.44 76.75,56.53C71.45,59.59 67.3,65.75 67.3,70.55C67.3,72.22 67.83,73.59 68.76,74.54C68.6,74.52 68.44,74.5 68.28,74.47C66.05,73.97 64.84,72.25 64.84,69.51ZM76.15,72.71C74.81,73.4 73.55,73.76 72.46,73.76C70.52,73.76 69.49,72.65 69.49,70.55C69.49,66.59 73.32,61.04 77.85,58.43C79.41,57.53 80.89,57.06 82.14,57.06C84.08,57.06 85.11,58.17 85.11,60.27C85.11,63.75 82.15,68.46 78.35,71.32C77.8,71.7 77.24,72.06 76.68,72.43C76.5,72.53 76.33,72.62 76.16,72.72L76.15,72.71ZM77.84,42.56C80.34,41.12 82.78,40.35 84.9,40.35C89.16,40.35 91.7,43.4 91.7,48.52C91.7,49.33 91.64,50.16 91.52,51C90.59,48.19 88.21,46.53 84.85,46.53C82.42,46.53 79.62,47.41 76.75,49.06C69.91,53.01 64.19,60.58 62.91,66.96C62.91,66.78 62.89,66.6 62.89,66.41C62.89,58.01 69.6,47.31 77.84,42.55L77.84,42.56ZM61.41,70.93C62.5,74.01 64.78,76.06 67.87,76.62C68.3,76.71 68.75,76.77 69.22,76.79C63.75,79.63 58.53,81.53 54.06,82.33C57.91,78.7 60.15,74.29 61.4,70.92L61.41,70.93Z" style="fill:rgb(166,102,53);fill-rule:nonzero;"/>
.cls-3 { <path d="M48.68,83.86C65.74,84.31 85.27,68.67 95.24,58.7" style="fill-rule:nonzero;"/>
stroke: #7d4e2a; <path d="M87.32,38.72L72.42,46.23C71.72,46.63 71.5,46.79 71.1,47.14" style="fill-rule:nonzero;"/>
} <path d="M58.22,82.57L37.41,70.56" style="fill:none;fill-rule:nonzero;"/>
</g>
.cls-4 { </g>
fill: #a66635; </g>
} </svg>
.cls-4, .cls-5 {
opacity: 0;
}
.cls-4, .cls-5, .cls-6, .cls-7, .cls-8, .cls-9 {
stroke-width: 0px;
}
.cls-5 {
fill: #3c3c3b;
}
.cls-6 {
fill: #afc8df;
}
.cls-7 {
fill: #97b3cd;
}
.cls-8 {
fill: #7d95ac;
}
.cls-9 {
fill: #edf2f6;
}
</style>
</defs>
<ellipse class="cls-9" cx="59.96" cy="64.42" rx="59.96" ry="34.58"/>
<g>
<path class="cls-5" d="m74.08,46.16c-.44-.42-1.14-.4-1.55.05-1.23,1.31-2.55,2.59-3.91,3.86,2.73-4.53,4.4-9.49,4.4-13.87,0-6.3-3.53-10.37-8.99-10.37-2.5,0-5.32.86-8.16,2.5-8.5,4.91-15.47,15.76-16.02,24.67,0,.03-.02.06-.02.09-.02.11-1.95,11.31-10.65,17.37-1.32.03-2.54-.05-3.64-.28-.59-.12-1.17.26-1.3.86-.12.59.26,1.17.86,1.3,1.11.23,2.31.34,3.59.34,6.44,0,14.93-2.82,23.98-8.05,1.52-.88,3.03-1.81,4.52-2.79.19-.11.38-.23.57-.35.04-.03.09-.06.13-.08.24-.15.47-.3.7-.46,0,0,.02-.01.03-.02.53-.36,1.05-.74,1.56-1.14,5.08-3.62,9.87-7.75,13.94-12.06.42-.44.4-1.14-.04-1.55Zm-30.12,11.01c0-6.08,5.83-14.39,13.01-18.53,2.54-1.46,4.96-2.24,7-2.24,3.17,0,4.91,1.82,4.91,5.12,0,2.48-.98,5.33-2.62,8.12.11-.58.17-1.16.17-1.71,0-3.29-2.03-5.41-5.17-5.41-1.63,0-3.5.58-5.39,1.67-5.3,3.06-9.45,9.22-9.45,14.02,0,1.67.53,3.04,1.46,3.99-.16-.02-.32-.04-.48-.07-2.23-.5-3.44-2.22-3.44-4.96Zm11.31,3.2c-1.34.69-2.6,1.05-3.69,1.05-1.94,0-2.97-1.11-2.97-3.21,0-3.96,3.83-9.51,8.36-12.12,1.56-.9,3.04-1.37,4.29-1.37,1.94,0,2.97,1.11,2.97,3.21,0,3.48-2.96,8.19-6.76,11.05-.55.38-1.11.74-1.67,1.11-.18.1-.35.19-.52.29Zm1.69-30.15c2.5-1.44,4.94-2.21,7.06-2.21,4.26,0,6.8,3.05,6.8,8.17,0,.81-.06,1.64-.18,2.48-.93-2.81-3.31-4.47-6.67-4.47-2.43,0-5.23.88-8.1,2.53-6.84,3.95-12.56,11.52-13.84,17.9,0-.18-.02-.36-.02-.55,0-8.4,6.71-19.1,14.95-23.86Zm-16.43,28.37c1.09,3.08,3.37,5.13,6.46,5.69.43.09.88.15,1.35.17-5.47,2.84-10.69,4.74-15.16,5.54,3.85-3.63,6.09-8.04,7.34-11.41Z"/>
<path class="cls-3" d="m27.8,71.52c17.06.45,36.59-15.19,46.56-25.16"/>
<line class="cls-3" x1="46.7" y1="66.61" x2="51.7" y2="43.79"/>
<line class="cls-3" x1="66.69" y1="53.27" x2="63.26" y2="39.41"/>
<path class="cls-3" d="m67.18,26.01l-15.64,7.88c-.7.4-.92.56-1.32.91"/>
<line class="cls-2" x1="67.66" y1="78.58" x2="72.58" y2="56.13"/>
<line class="cls-2" x1="86.47" y1="66.17" x2="81.41" y2="45.68"/>
<path class="cls-6" d="m47.64,47.78l.14-1.02c.2-1.45,1.2-2.99,2.36-3.66.04-.03.09-.05.13-.07l10.37-5.33s.09-.05.13-.07c.96-.55,1.83-1.73,2.18-2.97L71.84,2.47c.24-.85.84-1.65,1.49-2.03.06-.03.12-.07.18-.09l.5-.22c.38-.17.71-.16.96-.02l20.26,11.77c-.25-.14-.58-.15-.96.02l-.5.22c-.06.03-.12.06-.18.09-.65.38-1.25,1.18-1.49,2.03l-8.88,32.19c-.36,1.24-1.22,2.42-2.18,2.97-.04.03-.09.05-.13.07l-10.37,5.33s-.09.05-.13.07c-1.16.67-2.17,2.21-2.36,3.66l-.14,1.02c-.07.53.08.92.35,1.08l-20.26-11.77c-.28-.16-.43-.55-.35-1.08Z"/>
<path class="cls-7" d="m93.59,12.21c-.65.38-1.25,1.18-1.49,2.03l-8.88,32.19c-.36,1.24-1.22,2.42-2.18,2.97-.04.03-.09.05-.13.07l-10.37,5.33s-.09.05-.13.07c-1.16.67-2.17,2.21-2.36,3.66l-.14,1.02c-.13.97.47,1.45,1.26.99l15.17-8.76c.77-.44,1.46-1.39,1.73-2.38l9.54-35.79c.36-1.34-.29-2.18-1.33-1.72l-.5.22c-.06.03-.12.06-.18.09Z"/>
<path class="cls-8" d="m68.83,56.45l-20.26-11.77c-.41.64-.7,1.37-.79,2.07l-.14,1.02c-.07.53.08.92.35,1.08l20.26,11.77c-.28-.16-.43-.55-.35-1.08l.14-1.02c.1-.71.39-1.44.79-2.07Z"/>
<path class="cls-8" d="m61.85,36.69c.49-.58.89-1.29,1.1-2.04L71.84,2.47c.1-.35.27-.68.47-.98l20.26,11.77c-.2.31-.37.64-.47.99l-8.88,32.19c-.21.74-.61,1.46-1.1,2.04l-20.26-11.77Z"/>
<path class="cls-4" d="m94.96,58.5c-.44-.42-1.14-.4-1.55.05-1.23,1.31-2.55,2.59-3.91,3.86,2.73-4.53,4.4-9.49,4.4-13.87,0-6.3-3.53-10.37-8.99-10.37-2.5,0-5.32.86-8.16,2.5-8.5,4.91-15.47,15.76-16.02,24.67,0,.03-.02.06-.02.09-.02.11-1.95,11.31-10.65,17.37-1.32.03-2.54-.05-3.64-.28-.59-.12-1.17.26-1.3.86-.12.59.26,1.17.86,1.3,1.11.23,2.31.34,3.59.34,6.44,0,14.93-2.82,23.98-8.05,1.52-.88,3.03-1.81,4.52-2.79.19-.11.38-.23.57-.35.04-.03.09-.06.13-.08.24-.15.47-.3.7-.46,0,0,.02-.01.03-.02.53-.36,1.05-.74,1.56-1.14,5.08-3.62,9.87-7.75,13.94-12.06.42-.44.4-1.14-.04-1.55Zm-30.12,11.01c0-6.08,5.83-14.39,13.01-18.53,2.54-1.46,4.96-2.24,7-2.24,3.17,0,4.91,1.82,4.91,5.12,0,2.48-.98,5.33-2.62,8.12.11-.58.17-1.16.17-1.71,0-3.29-2.03-5.41-5.17-5.41-1.63,0-3.5.58-5.39,1.67-5.3,3.06-9.45,9.22-9.45,14.02,0,1.67.53,3.04,1.46,3.99-.16-.02-.32-.04-.48-.07-2.23-.5-3.44-2.22-3.44-4.96Zm11.31,3.2c-1.34.69-2.6,1.05-3.69,1.05-1.94,0-2.97-1.11-2.97-3.21,0-3.96,3.83-9.51,8.36-12.12,1.56-.9,3.04-1.37,4.29-1.37,1.94,0,2.97,1.11,2.97,3.21,0,3.48-2.96,8.19-6.76,11.05-.55.38-1.11.74-1.67,1.11-.18.1-.35.19-.52.29Zm1.69-30.15c2.5-1.44,4.94-2.21,7.06-2.21,4.26,0,6.8,3.05,6.8,8.17,0,.81-.06,1.64-.18,2.48-.93-2.81-3.31-4.47-6.67-4.47-2.43,0-5.23.88-8.1,2.53-6.84,3.95-12.56,11.52-13.84,17.9,0-.18-.02-.36-.02-.55,0-8.4,6.71-19.1,14.95-23.86Zm-16.43,28.37c1.09,3.08,3.37,5.13,6.46,5.69.43.09.88.15,1.35.17-5.47,2.84-10.69,4.74-15.16,5.54,3.85-3.63,6.09-8.04,7.34-11.41Z"/>
<path class="cls-2" d="m48.68,83.86c17.06.45,36.59-15.19,46.56-25.16"/>
<path class="cls-2" d="m87.32,38.72l-14.9,7.51c-.7.4-.92.56-1.32.91"/>
<line class="cls-1" x1="58.22" y1="82.57" x2="37.41" y2="70.56"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 7.6 KiB