Merge branch 'develop' into feature/vbv-676-berufsbildner-2

This commit is contained in:
Christian Cueni 2024-08-06 16:04:44 +02:00
commit 77dce844d3
19 changed files with 762 additions and 230 deletions

View File

@ -6,9 +6,14 @@ import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import { computed, ref, watch } from "vue";
import { useTranslation } from "i18next-vue";
import _ from "lodash";
import type { DashboardPersonCourseSessionType } from "@/services/dashboard";
import {
type DashboardPersonCourseSessionType,
exportPersons,
} from "@/services/dashboard";
import { useRouteQuery } from "@vueuse/router";
import type { DashboardPersonsPageMode } from "@/types";
import type { DashboardPersonsPageMode, StatisticsFilterItem } from "@/types";
import { useUserStore } from "@/stores/user";
import { exportDataAsXls } from "@/utils/export";
log.debug("DashboardPersonsPage created");
@ -28,6 +33,7 @@ type MenuItem = {
};
const { t } = useTranslation();
const userStore = useUserStore();
const { loading, dashboardPersons } = useDashboardPersonsDueDates(props.mode);
@ -227,6 +233,32 @@ function personRoleDisplayValue(personCourseSession: DashboardPersonCourseSessio
return "";
}
function exportData() {
const courseSessionIdsSet = new Set<string>();
// get all course session ids from users
if (selectedSession.value.id === UNFILTERED) {
for (const person of filteredPersons.value) {
for (const courseSession of person.course_sessions) {
courseSessionIdsSet.add(courseSession.id);
}
}
} else {
courseSessionIdsSet.add(selectedSession.value.id);
}
// construct StatisticsFilterItems for export call
const items: StatisticsFilterItem[] = [];
for (const csId of courseSessionIdsSet) {
items.push({
_id: "",
course_session_id: csId,
generation: "",
circle_id: "",
});
}
exportDataAsXls(items, exportPersons, userStore.language);
}
watch(selectedCourse, () => {
selectedRegion.value = regions.value[0];
});
@ -253,7 +285,18 @@ watch(selectedRegion, () => {
<it-icon-arrow-left class="-ml-1 mr-1 h-5 w-5"></it-icon-arrow-left>
<span class="inline">{{ $t("general.back") }}</span>
</router-link>
<h2 class="my-4">{{ $t("a.Personen") }}</h2>
<div class="mb-10 flex items-center justify-between">
<h2 class="my-4">{{ $t("a.Personen") }}</h2>
<button
v-if="userStore.course_session_experts.length > 0"
class="flex"
data-cy="export-button"
@click="exportData"
>
<it-icon-export></it-icon-export>
<span class="ml inline-block">{{ $t("a.Als Excel exportieren") }}</span>
</button>
</div>
<div class="bg-white px-4 py-2">
<section
v-if="filtersVisible"

View File

@ -293,7 +293,7 @@ const executePayment = async () => {
{{ formErrors.personal.join(", ") }}
</p>
<section v-if="address.payment_method !== 'cembra_byjuno'">
<section>
<div class="mt-4">
<button
v-if="!withCompanyAddress"

View File

@ -241,6 +241,15 @@ export async function exportCompetenceElements(
});
}
export async function exportPersons(
data: XlsExportRequestData,
language: string
): Promise<XlsExportResponseData> {
return await itPost("/api/dashboard/export/persons/", data, {
headers: { "Accept-Language": language },
});
}
export function courseIdForCourseSlug(
dashboardConfigs: DashboardCourseConfigType[],
courseSlug: string

View File

@ -26,7 +26,7 @@ function getCurrentDate() {
function verifyExportFileExists(fileName) {
const downloadsFolder = Cypress.config("downloadsFolder");
cy.readFile(
path.join(downloadsFolder, `${fileName}_${getCurrentDate()}.xlsx`)
path.join(downloadsFolder, `${fileName}_${getCurrentDate()}.xlsx`),
).should("exist");
}
@ -39,7 +39,7 @@ function testExport(url, fileName) {
describe("dashboardExport.cy.js", () => {
beforeEach(() => {
cy.manageCommand(
"cypress_reset --create-assignment-evaluation --create-feedback-responses --create-course-completion-performance-criteria --create-attendance-days"
"cypress_reset --create-assignment-evaluation --create-feedback-responses --create-course-completion-performance-criteria --create-attendance-days",
);
});
@ -55,13 +55,17 @@ describe("dashboardExport.cy.js", () => {
it("should download the competence elements export", () => {
testExport(
"/statistic/test-lehrgang/assignment",
"export_kompetenznachweis_elemente"
"export_kompetenznachweis_elemente",
);
});
it("should download the feedback export", () => {
testExport("/statistic/test-lehrgang/feedback", "export_feedback");
});
it("should download the person export", () => {
testExport("/dashboard/persons", "export_personen");
});
});
describe("as trainer", () => {
@ -76,12 +80,16 @@ describe("dashboardExport.cy.js", () => {
it("should download the competence elements export", () => {
testExport(
"/statistic/test-lehrgang/assignment",
"export_kompetenznachweis_elemente"
"export_kompetenznachweis_elemente",
);
});
it("should download the feedback export", () => {
testExport("/statistic/test-lehrgang/feedback", "export_feedback");
});
it("should download the person export", () => {
testExport("/dashboard/persons", "export_personen");
});
});
});

View File

@ -44,6 +44,7 @@ from vbv_lernwelt.dashboard.views import (
export_attendance_as_xsl,
export_competence_elements_as_xsl,
export_feedback_as_xsl,
export_persons_as_xsl,
get_dashboard_config,
get_dashboard_due_dates,
get_dashboard_persons,
@ -143,6 +144,7 @@ urlpatterns = [
path(r"api/dashboard/export/competence_elements/", export_competence_elements_as_xsl,
name="export_certificate_as_xsl"),
path(r"api/dashboard/export/feedback/", export_feedback_as_xsl, name="export_feedback_as_xsl"),
path(r"api/dashboard/export/persons/", export_persons_as_xsl, name="export_persons_as_xsl"),
# course
path(r"api/course/sessions/", get_course_sessions, name="get_course_sessions"),

View File

@ -87,7 +87,7 @@ msgstr ""
msgid "Lehrgang-Seite"
msgstr ""
#: vbv_lernwelt/course/models.py:278
#: vbv_lernwelt/dashboard/person_export.py:116
msgid "Teilnehmer"
msgstr ""
@ -111,7 +111,7 @@ msgstr ""
msgid "Versicherungsvermittler-Lehrgang"
msgstr ""
#: vbv_lernwelt/course/models.py:351
#: vbv_lernwelt/course/models.py:350
msgid "ÜK-Lehrgang"
msgstr ""
@ -155,10 +155,30 @@ msgstr ""
msgid "Email"
msgstr "Email"
#: vbv_lernwelt/course_session/services/export_attendance.py:138
#: vbv_lernwelt/course_session/services/export_attendance.py:127
msgid "Lehrvertragsnummer"
msgstr ""
#: vbv_lernwelt/dashboard/person_export.py:16
msgid "export_personen"
msgstr ""
#: vbv_lernwelt/dashboard/person_export.py:68
msgid "Telefon"
msgstr ""
#: vbv_lernwelt/dashboard/person_export.py:69
msgid "Rolle"
msgstr ""
#: vbv_lernwelt/dashboard/person_export.py:118
msgid "Trainer"
msgstr ""
#: vbv_lernwelt/dashboard/person_export.py:120
msgid "Regionenleiter"
msgstr ""
#: vbv_lernwelt/feedback/export.py:19
msgid "export_feedback"
msgstr ""

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-07-27 20:59+0200\n"
"POT-Creation-Date: 2024-07-30 11:16+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -88,8 +88,9 @@ msgid "Lehrgang-Seite"
msgstr ""
#: vbv_lernwelt/course/models.py:278
#: vbv_lernwelt/dashboard/person_export.py:116
msgid "Teilnehmer"
msgstr ""
msgstr "Participant"
#: vbv_lernwelt/course/models.py:279
msgid "Experte/Trainer"
@ -120,7 +121,6 @@ msgid "export_anwesenheit"
msgstr "export_presence"
#: vbv_lernwelt/course_session/services/export_attendance.py:86
#| msgid "Anwesenheit"
msgid "Optionale Anwesenheit"
msgstr "Présence facultative"
@ -160,6 +160,26 @@ msgstr "E-mail"
msgid "Lehrvertragsnummer"
msgstr "Numéro de contrat d'apprentissage"
#: vbv_lernwelt/dashboard/person_export.py:16
msgid "export_personen"
msgstr "export_personnes"
#: vbv_lernwelt/dashboard/person_export.py:68
msgid "Telefon"
msgstr "Téléphone"
#: vbv_lernwelt/dashboard/person_export.py:69
msgid "Rolle"
msgstr "Rôle"
#: vbv_lernwelt/dashboard/person_export.py:118
msgid "Trainer"
msgstr "Formateur / Formatrice"
#: vbv_lernwelt/dashboard/person_export.py:120
msgid "Regionenleiter"
msgstr "Responsable CI"
#: vbv_lernwelt/feedback/export.py:19
msgid "export_feedback"
msgstr "export_feedback"

View File

@ -8,7 +8,7 @@ msgid ""
msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2024-07-27 20:59+0200\n"
"POT-Creation-Date: 2024-07-30 11:16+0200\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n"
@ -88,8 +88,9 @@ msgid "Lehrgang-Seite"
msgstr ""
#: vbv_lernwelt/course/models.py:278
#: vbv_lernwelt/dashboard/person_export.py:116
msgid "Teilnehmer"
msgstr ""
msgstr "Partecipante"
#: vbv_lernwelt/course/models.py:279
msgid "Experte/Trainer"
@ -120,7 +121,6 @@ msgid "export_anwesenheit"
msgstr "esportazione_presenza"
#: vbv_lernwelt/course_session/services/export_attendance.py:86
#| msgid "Anwesenheit"
msgid "Optionale Anwesenheit"
msgstr "Presenza opzionale"
@ -160,6 +160,28 @@ msgstr "Email"
msgid "Lehrvertragsnummer"
msgstr "Numero di contratto di tirocinio"
#: vbv_lernwelt/dashboard/person_export.py:16
#, fuzzy
#| msgid "export_anwesenheit"
msgid "export_personen"
msgstr "esportazione_persone"
#: vbv_lernwelt/dashboard/person_export.py:68
msgid "Telefon"
msgstr "Telefono"
#: vbv_lernwelt/dashboard/person_export.py:69
msgid "Rolle"
msgstr "Ruolo"
#: vbv_lernwelt/dashboard/person_export.py:118
msgid "Trainer"
msgstr "Trainer"
#: vbv_lernwelt/dashboard/person_export.py:120
msgid "Regionenleiter"
msgstr "Responsabile CI"
#: vbv_lernwelt/feedback/export.py:19
msgid "export_feedback"
msgstr "esportazione_feedback"

View File

@ -0,0 +1,30 @@
import djclick as click
import structlog
from django.db.models import Count
from vbv_lernwelt.course.models import CourseSessionUser
logger = structlog.get_logger(__name__)
@click.command()
def command():
VV_COURSE_SESSIONS = [1, 2, 3] # DE, FR, IT
# Aggregation of users per organisation for a specific course session
user_counts = (
CourseSessionUser.objects.filter(course_session__id__in=VV_COURSE_SESSIONS)
.values("user__organisation__organisation_id", "user__organisation__name_de")
.annotate(user_count=Count("id"))
.order_by("user__organisation__organisation_id")
)
for entry in user_counts:
print(
f"Organisation Name: {entry['user__organisation__name_de']}, "
f"User Count: {entry['user_count']}"
)
print(
f"Total number of users: {sum([entry['user_count'] for entry in user_counts])}"
)

View File

@ -0,0 +1,125 @@
from io import BytesIO
from typing import Optional
import structlog
from django.utils.translation import gettext_lazy as _
from openpyxl import Workbook
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.course_session.services.export_attendance import (
add_user_headers,
make_export_filename,
sanitize_sheet_name,
)
from vbv_lernwelt.dashboard.utils import create_person_list_with_roles
PERSONS_EXPORT_FILENAME = _("export_personen")
logger = structlog.get_logger(__name__)
def export_persons(
user: User,
course_session_ids: list[str],
save_as_file: bool = False,
) -> Optional[bytes]:
if not course_session_ids:
return
wb = Workbook()
# remove the first sheet is just easier than keeping track of the active sheet
wb.remove(wb.active)
user_with_roles = create_person_list_with_roles(
user, course_session_ids, include_private_data=True
)
course_sessions = CourseSession.objects.filter(id__in=course_session_ids)
for cs in course_sessions:
_create_sheet(
wb,
cs.title,
cs.id,
user_with_roles,
)
if save_as_file:
wb.save(make_export_filename(PERSONS_EXPORT_FILENAME))
else:
output = BytesIO()
wb.save(output)
output.seek(0)
return output.getvalue()
def _create_sheet(
wb: Workbook,
title: str,
cs_id: int,
user_with_roles,
):
sheet = wb.create_sheet(title=sanitize_sheet_name(title))
if len(user_with_roles) == 0:
return sheet
# headers
# common user headers, Circle <title> <learningcontenttitle> bestanden, Circle <title> <learningcontenttitle> Resultat, ...
col_idx = add_user_headers(sheet)
sheet.cell(row=1, column=col_idx, value=str(_("Telefon")))
sheet.cell(row=1, column=col_idx + 1, value=str(_("Rolle")))
_add_rows(sheet, user_with_roles, cs_id)
return sheet
def _add_rows(
sheet,
users,
course_session_id,
):
idx_offset = 0
for row_idx, user in enumerate(users, start=2):
def get_user_cs_by_id(user_cs, cs_id):
return next((cs for cs in user_cs if int(cs.get("id")) == cs_id), None)
user_cs = get_user_cs_by_id(user["course_sessions"], course_session_id)
if not user_cs:
logger.warning(
"User not found in course session",
user_id=user["user_id"],
course_session_id=course_session_id,
)
idx_offset += 1
continue
user_role = _role_as_string(user_cs.get("user_role"))
idx = row_idx - idx_offset
sheet.cell(row=idx, column=1, value=user["first_name"])
sheet.cell(row=idx, column=2, value=user["last_name"])
sheet.cell(row=idx, column=3, value=user["email"])
sheet.cell(row=idx, column=4, value=user.get("Lehrvertragsnummer", ""))
sheet.cell(
row=idx,
column=5,
value=user.get("phone_number", ""),
)
sheet.cell(row=idx, column=6, value=user_role)
def _role_as_string(role):
if role == "MEMBER":
return str(_("Teilnehmer"))
elif role == "EXPERT":
return str(_("Trainer"))
elif role == "SUPERVISOR":
return str(_("Regionenleiter"))
else:
return role

View File

@ -0,0 +1,186 @@
import io
from django.utils.translation import activate
from openpyxl import load_workbook
from vbv_lernwelt.core.constants import (
TEST_STUDENT1_USER_ID,
TEST_STUDENT2_USER_ID,
TEST_STUDENT3_USER_ID,
TEST_TRAINER1_USER_ID,
TEST_TRAINER2_USER_ID,
)
from vbv_lernwelt.core.create_default_users import create_default_users
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.creators.test_course import create_test_course
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.course_session.tests.test_attendance_export import ExportBaseTestCase
from vbv_lernwelt.dashboard.person_export import export_persons
from vbv_lernwelt.learnpath.models import Circle
class PersonsExportTestCase(ExportBaseTestCase):
def setUp(self):
super().setUp()
create_default_users()
create_test_course(include_vv=False, with_sessions=True)
self.course_session_be = CourseSession.objects.get(title="Test Bern 2022 a")
self.course_session_zh = CourseSession.objects.get(title="Test Zürich 2022 a")
self.circle_fahrzeug = Circle.objects.get(
slug="test-lehrgang-lp-circle-fahrzeug"
)
self.circle_reisen = Circle.objects.get(slug="test-lehrgang-lp-circle-fahrzeug")
self.test_trainer1 = User.objects.get(id=TEST_TRAINER1_USER_ID)
self.test_trainer2 = User.objects.get(id=TEST_TRAINER2_USER_ID)
self.test_student1 = User.objects.get(id=TEST_STUDENT1_USER_ID)
self.test_student2 = User.objects.get(id=TEST_STUDENT2_USER_ID)
self.test_student3 = User.objects.get(id=TEST_STUDENT3_USER_ID)
self.test_student1_row = [
self.test_student1.first_name,
self.test_student1.last_name,
self.test_student1.email,
None,
None,
"Teilnehmer",
]
self.test_student2_row = [
self.test_student2.first_name,
self.test_student2.last_name,
self.test_student2.email,
None,
None,
"Teilnehmer",
]
self.test_student3_row = [
self.test_student3.first_name,
self.test_student3.last_name,
self.test_student3.email,
None,
None,
"Teilnehmer",
]
self.test_trainer1_row = [
self.test_trainer1.first_name,
self.test_trainer1.last_name,
self.test_trainer1.email,
None,
None,
"Trainer",
]
self.test_trainer2_row = [
self.test_trainer2.first_name,
self.test_trainer2.last_name,
self.test_trainer2.email,
None,
None,
"Trainer",
]
def _generate_expected_data(self, rows):
expected_data = [
self._make_header(),
]
for r in rows:
expected_data.append(r)
return expected_data
def _generate_workbook(self, user, course_session_ids):
export_data = io.BytesIO(
export_persons(user, course_session_ids, save_as_file=False)
)
return load_workbook(export_data)
def _make_header(self):
return [
"Vorname",
"Nachname",
"Email",
"Lehrvertragsnummer",
"Telefon",
"Rolle",
]
def test_export_persons(self):
wb = self._generate_workbook(self.test_trainer1, [self.course_session_be.id])
self.assertEqual(len(wb.sheetnames), 1)
self.assertEqual(wb.sheetnames[0], "Test Bern 2022 a")
wb.active = wb["Test Bern 2022 a"]
data = self._generate_expected_data(
[
self.test_student1_row,
self.test_student2_row,
self.test_student3_row,
self.test_trainer1_row,
]
)
self._check_export(wb, data, 4, 6)
wb = self._generate_workbook(self.test_trainer2, [self.course_session_zh.id])
self.assertEqual(len(wb.sheetnames), 1)
self.assertEqual(wb.sheetnames[0], "Test Zürich 2022 a")
wb.active = wb["Test Zürich 2022 a"]
data = self._generate_expected_data(
[self.test_student2_row, self.test_trainer2_row]
)
self._check_export(wb, data, 3, 6)
def test_cannot_export_other_session(self):
wb = self._generate_workbook(self.test_trainer1, [self.course_session_zh.id])
self.assertEqual(len(wb.sheetnames), 1)
self.assertEqual(wb.sheetnames[0], "Test Zürich 2022 a")
wb.active = wb["Test Zürich 2022 a"]
data = self._generate_expected_data([[None] * 6])
self._check_export(wb, data, 1, 6)
def test_export_in_fr(self):
activate("fr")
wb = self._generate_workbook(self.test_trainer1, [self.course_session_be.id])
self.assertEqual(len(wb.sheetnames), 1)
self.assertEqual(wb.sheetnames[0], "Test Bern 2022 a")
wb.active = wb["Test Bern 2022 a"]
header = [
"Prénom",
"Nom de famille",
"E-mail",
"Numéro de contrat d'apprentissage",
"Téléphone",
"Rôle",
]
self.assertEqual([cell.value for cell in wb.active[1]], header)
self.assertEqual(wb.active.cell(row=2, column=6).value, "Participant")
self.assertEqual(
wb.active.cell(row=5, column=6).value, "Formateur / Formatrice"
)
def test_export_in_it(self):
activate("it")
wb = self._generate_workbook(self.test_trainer1, [self.course_session_be.id])
self.assertEqual(len(wb.sheetnames), 1)
self.assertEqual(wb.sheetnames[0], "Test Bern 2022 a")
wb.active = wb["Test Bern 2022 a"]
header = [
"Nome",
"Cognome",
"Email",
"Numero di contratto di tirocinio",
"Telefono",
"Ruolo",
]
self.assertEqual([cell.value for cell in wb.active[1]], header)
self.assertEqual(wb.active.cell(row=2, column=6).value, "Partecipante")
self.assertEqual(wb.active.cell(row=5, column=6).value, "Trainer")

View File

@ -0,0 +1,188 @@
from dataclasses import dataclass
from typing import List, Set
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseSession, CourseSessionUser
from vbv_lernwelt.course_session_group.models import CourseSessionGroup
from vbv_lernwelt.learning_mentor.models import (
AgentParticipantRelation,
AgentParticipantRoleType,
)
@dataclass(frozen=True)
class CourseSessionWithRoles:
_original: CourseSession
roles: Set[str]
def __getattr__(self, name: str):
# Delegate attribute access to the _original CourseSession object
return getattr(self._original, name)
def save(self, *args, **kwargs):
raise NotImplementedError("This proxy object cannot be saved.")
def get_course_sessions_with_roles_for_user(user: User) -> List[CourseSessionWithRoles]:
result_course_sessions = {}
# participant/member/expert course sessions
csu_qs = CourseSessionUser.objects.filter(user=user).prefetch_related(
"course_session", "course_session__course"
)
for csu in csu_qs:
cs = csu.course_session
# member/expert is mutually exclusive...
cs.roles = {csu.role}
result_course_sessions[cs.id] = cs
# enrich with supervisor course sessions
csg_qs = CourseSessionGroup.objects.filter(supervisor=user).prefetch_related(
"course_session", "course_session__course"
)
for csg in csg_qs:
for cs in csg.course_session.all():
cs.roles = set()
cs = result_course_sessions.get(cs.id, cs)
cs.roles.add("SUPERVISOR")
result_course_sessions[cs.id] = cs
# enrich with mentor course sessions
agent_qs = AgentParticipantRelation.objects.filter(agent=user).prefetch_related(
"participant__course_session", "participant__course_session__course"
)
for agent_relation in agent_qs:
cs = agent_relation.participant.course_session
cs.roles = set()
cs = result_course_sessions.get(cs.id, cs)
cs.roles.add(agent_relation.role)
result_course_sessions[cs.id] = cs
return [
CourseSessionWithRoles(cs, cs.roles) for cs in result_course_sessions.values()
]
def has_cs_role(roles: Set[str]) -> bool:
return bool(roles & {"SUPERVISOR", "EXPERT", "MEMBER"})
def user_role(roles: Set[str]) -> str:
if "SUPERVISOR" in roles:
return "SUPERVISOR"
if "EXPERT" in roles:
return "EXPERT"
if "MEMBER" in roles:
return "MEMBER"
return "LEARNING_MENTOR"
def create_course_session_dict(course_session_object, my_role, user_role):
return {
"id": str(course_session_object.id),
"session_title": course_session_object.title,
"course_id": str(course_session_object.course.id),
"course_title": course_session_object.course.title,
"course_slug": course_session_object.course.slug,
"region": course_session_object.region,
"generation": course_session_object.generation,
"my_role": my_role,
"user_role": user_role,
"is_uk": course_session_object.course.configuration.is_uk,
"is_vv": course_session_object.course.configuration.is_vv,
}
def create_person_list_with_roles(
user, course_session_ids=None, include_private_data=False
):
def create_user_dict(user_object):
def create_user_dict(user_object):
return {
"user_id": user_object.id,
"first_name": user_object.first_name,
"last_name": user_object.last_name,
"email": user_object.email,
"avatar_url_small": user_object.avatar_url_small,
"avatar_url": user_object.avatar_url,
"course_sessions": [],
}
course_sessions = get_course_sessions_with_roles_for_user(user)
result_persons = {}
for cs in course_sessions:
if has_cs_role(cs.roles) and cs.course.configuration.is_uk:
course_session_users = CourseSessionUser.objects.filter(
course_session=cs.id
).select_related("user")
my_role = user_role(cs.roles)
for csu in course_session_users:
person_data = result_persons.get(
csu.user.id, create_user_dict(csu.user)
)
person_data["course_sessions"].append(
create_course_session_dict(cs, my_role, csu.role)
)
result_persons[csu.user.id] = person_data
# add persons where request.user is mentor
for cs in course_sessions:
def _add_agent_relation(my_role, user_role):
course_session_entry = create_course_session_dict(
cs, my_role, user_role
)
participant_user = relation.participant.user
if participant_user.id not in result_persons:
person_data = create_user_dict(participant_user)
person_data["course_sessions"] = [course_session_entry]
result_persons[participant_user.id] = person_data
else:
# user is already in result_persons
result_persons[participant_user.id]["course_sessions"].append(
course_session_entry
)
if "LEARNING_MENTOR" in cs.roles:
for relation in AgentParticipantRelation.objects.filter(
agent=user,
participant__course_session_id=cs.id,
role="LEARNING_MENTOR",
):
_add_agent_relation("LEARNING_MENTOR", "PARTICIPANT")
if "BERUFSBILDNER" in cs.roles:
for relation in AgentParticipantRelation.objects.filter(
agent=user,
participant__course_session_id=cs.id,
role="BERUFSBILDNER",
):
_add_agent_relation("BERUFSBILDNER", "PARTICIPANT")
# add persons where request.user is lerning mentee
mentor_relation_qs = AgentParticipantRelation.objects.filter(
participant__user=user,
role=AgentParticipantRoleType.LEARNING_MENTOR.value,
).prefetch_related("agent")
for mentor_relation in mentor_relation_qs:
cs = mentor_relation.participant.course_session
course_session_entry = create_course_session_dict(
cs,
"PARTICIPANT",
"LEARNING_MENTOR",
)
if mentor_relation.agent.id not in result_persons:
person_data = create_user_dict(mentor_relation.agent)
person_data["course_sessions"] = [course_session_entry]
result_persons[mentor_relation.agent.id] = person_data
else:
# user is already in result_persons
result_persons[mentor_relation.agent.id]["course_sessions"].append(
course_session_entry
)
return result_persons.values()

View File

@ -2,7 +2,7 @@ import base64
from dataclasses import asdict, dataclass
from datetime import date
from enum import Enum
from typing import List, Set, Tuple
from typing import List, Tuple
from django.db.models import Q
from django.http import HttpResponse
@ -24,18 +24,21 @@ from vbv_lernwelt.competence.services import (
query_competence_course_session_edoniq_tests,
)
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import (
CourseConfiguration,
CourseSession,
CourseSessionUser,
)
from vbv_lernwelt.course.models import CourseConfiguration, CourseSessionUser
from vbv_lernwelt.course.views import logger
from vbv_lernwelt.course_session.services.export_attendance import (
ATTENDANCE_EXPORT_FILENAME,
export_attendance,
make_export_filename,
)
from vbv_lernwelt.course_session_group.models import CourseSessionGroup
from vbv_lernwelt.dashboard.person_export import export_persons, PERSONS_EXPORT_FILENAME
from vbv_lernwelt.dashboard.utils import (
CourseSessionWithRoles,
create_course_session_dict,
create_person_list_with_roles,
get_course_sessions_with_roles_for_user,
user_role,
)
from vbv_lernwelt.duedate.models import DueDate
from vbv_lernwelt.duedate.serializers import DueDateSerializer
from vbv_lernwelt.feedback.export import (
@ -71,19 +74,6 @@ class RoleKeyType(Enum):
UNKNOWN_ROLE_KEY = "UnknownRoleKey"
@dataclass(frozen=True)
class CourseSessionWithRoles:
_original: CourseSession
roles: Set[str]
def __getattr__(self, name: str):
# Delegate attribute access to the _original CourseSession object
return getattr(self._original, name)
def save(self, *args, **kwargs):
raise NotImplementedError("This proxy object cannot be saved.")
@dataclass(frozen=True)
class CourseConfig:
course_id: str
@ -97,162 +87,6 @@ class CourseConfig:
session_to_continue_id: str | None
def get_course_sessions_with_roles_for_user(user: User) -> List[CourseSessionWithRoles]:
result_course_sessions = {}
# participant/member/expert course sessions
csu_qs = CourseSessionUser.objects.filter(user=user).prefetch_related(
"course_session", "course_session__course"
)
for csu in csu_qs:
cs = csu.course_session
# member/expert is mutually exclusive...
cs.roles = {csu.role}
result_course_sessions[cs.id] = cs
# enrich with supervisor course sessions
csg_qs = CourseSessionGroup.objects.filter(supervisor=user).prefetch_related(
"course_session", "course_session__course"
)
for csg in csg_qs:
for cs in csg.course_session.all():
cs.roles = set()
cs = result_course_sessions.get(cs.id, cs)
cs.roles.add("SUPERVISOR")
result_course_sessions[cs.id] = cs
# enrich with mentor course sessions
agent_qs = AgentParticipantRelation.objects.filter(agent=user).prefetch_related(
"participant__course_session", "participant__course_session__course"
)
for agent_relation in agent_qs:
cs = agent_relation.participant.course_session
cs.roles = set()
cs = result_course_sessions.get(cs.id, cs)
cs.roles.add(agent_relation.role)
result_course_sessions[cs.id] = cs
return [
CourseSessionWithRoles(cs, cs.roles) for cs in result_course_sessions.values()
]
def has_cs_role(roles: Set[str]) -> bool:
return bool(roles & {"SUPERVISOR", "EXPERT", "MEMBER"})
def user_role(roles: Set[str]) -> str:
if "SUPERVISOR" in roles:
return "SUPERVISOR"
if "EXPERT" in roles:
return "EXPERT"
if "MEMBER" in roles:
return "MEMBER"
return "LEARNING_MENTOR"
def _create_course_session_dict(course_session_object, my_role, user_role):
return {
"id": str(course_session_object.id),
"session_title": course_session_object.title,
"course_id": str(course_session_object.course.id),
"course_title": course_session_object.course.title,
"course_slug": course_session_object.course.slug,
"region": course_session_object.region,
"generation": course_session_object.generation,
"my_role": my_role,
"user_role": user_role,
"is_uk": course_session_object.course.configuration.is_uk,
"is_vv": course_session_object.course.configuration.is_vv,
}
def _create_person_list_with_roles(user):
def create_user_dict(user_object):
return {
"user_id": user_object.id,
"first_name": user_object.first_name,
"last_name": user_object.last_name,
"email": user_object.email,
"avatar_url_small": user_object.avatar_url_small,
"avatar_url": user_object.avatar_url,
"course_sessions": [],
}
course_sessions = get_course_sessions_with_roles_for_user(user)
result_persons = {}
for cs in course_sessions:
if has_cs_role(cs.roles) and cs.course.configuration.is_uk:
course_session_users = CourseSessionUser.objects.filter(
course_session=cs.id
).select_related("user")
my_role = user_role(cs.roles)
for csu in course_session_users:
person_data = result_persons.get(
csu.user.id, create_user_dict(csu.user)
)
person_data["course_sessions"].append(
_create_course_session_dict(cs, my_role, csu.role)
)
result_persons[csu.user.id] = person_data
# add persons where request.user is mentor
for cs in course_sessions:
def _add_agent_relation(my_role, user_role):
course_session_entry = _create_course_session_dict(cs, my_role, user_role)
participant_user = relation.participant.user
if participant_user.id not in result_persons:
person_data = create_user_dict(participant_user)
person_data["course_sessions"] = [course_session_entry]
result_persons[participant_user.id] = person_data
else:
# user is already in result_persons
result_persons[participant_user.id]["course_sessions"].append(
course_session_entry
)
if "LEARNING_MENTOR" in cs.roles:
for relation in AgentParticipantRelation.objects.filter(
agent=user, participant__course_session_id=cs.id, role="LEARNING_MENTOR"
):
_add_agent_relation("LEARNING_MENTOR", "PARTICIPANT")
if "BERUFSBILDNER" in cs.roles:
for relation in AgentParticipantRelation.objects.filter(
agent=user, participant__course_session_id=cs.id, role="BERUFSBILDNER"
):
_add_agent_relation("BERUFSBILDNER", "PARTICIPANT")
# add persons where request.user is lerning mentee
mentor_relation_qs = AgentParticipantRelation.objects.filter(
participant__user=user,
role=AgentParticipantRoleType.LEARNING_MENTOR.value,
).prefetch_related("agent")
for mentor_relation in mentor_relation_qs:
cs = mentor_relation.participant.course_session
course_session_entry = _create_course_session_dict(
cs,
"PARTICIPANT",
"LEARNING_MENTOR",
)
if mentor_relation.agent.id not in result_persons:
person_data = create_user_dict(mentor_relation.agent)
person_data["course_sessions"] = [course_session_entry]
result_persons[mentor_relation.agent.id] = person_data
else:
# user is already in result_persons
result_persons[mentor_relation.agent.id]["course_sessions"].append(
course_session_entry
)
return result_persons.values()
def _persons_list_add_competence_metrics(persons):
course_session_ids = {cs["id"] for p in persons for cs in p["course_sessions"]}
competence_assignments = query_competence_course_session_assignments(
@ -295,7 +129,7 @@ def _persons_list_add_competence_metrics(persons):
@api_view(["GET"])
def get_dashboard_persons(request):
try:
persons = list(_create_person_list_with_roles(request.user))
persons = list(create_person_list_with_roles(request.user))
if request.GET.get("with_competence_metrics", "") == "true":
persons = _persons_list_add_competence_metrics(persons)
@ -333,7 +167,7 @@ def get_dashboard_due_dates(request):
cs = course_session_map.get(due_date.course_session_id)
if cs:
data["course_session"] = _create_course_session_dict(
data["course_session"] = create_course_session_dict(
cs, my_role=user_role(cs.roles), user_role=""
)
result_due_dates.append(data)
@ -610,6 +444,20 @@ def export_feedback_as_xsl(request):
return _make_excel_response(data, FEEDBACK_EXPORT_FILE_NAME)
@api_view(["POST"])
def export_persons_as_xsl(request):
requested_course_session_ids = request.data.get("courseSessionIds", [])
course_sessions_with_roles = _get_permitted_courses_sessions_for_user(
request.user, requested_course_session_ids
) # noqa
data = export_persons(
request.user,
[cswr.id for cswr in course_sessions_with_roles],
)
return _make_excel_response(data, PERSONS_EXPORT_FILENAME)
def _get_permitted_courses_sessions_for_user(
user: User, requested_coursesession_ids: List[str]
) -> List[CourseSessionWithRoles]:

View File

@ -78,32 +78,32 @@ def create_customer_xml(checkout_information: CheckoutInformation):
first_name=checkout_information.first_name,
company_name=(
checkout_information.organisation_detail_name
if checkout_information.invoice_address == "org"
if checkout_information.abacus_use_organisation_data()
else ""
),
street=(
checkout_information.organisation_street
if checkout_information.invoice_address == "org"
if checkout_information.abacus_use_organisation_data()
else checkout_information.street
),
house_number=(
checkout_information.organisation_street_number
if checkout_information.invoice_address == "org"
if checkout_information.abacus_use_organisation_data()
else checkout_information.street_number
),
zip_code=(
checkout_information.organisation_postal_code
if checkout_information.invoice_address == "org"
if checkout_information.abacus_use_organisation_data()
else checkout_information.postal_code
),
city=(
checkout_information.organisation_city
if checkout_information.invoice_address == "org"
if checkout_information.abacus_use_organisation_data()
else checkout_information.city
),
country=(
checkout_information.organisation_country_id
if checkout_information.invoice_address == "org"
if checkout_information.abacus_use_organisation_data()
else checkout_information.country_id
),
language=customer.language,

View File

@ -128,3 +128,15 @@ class CheckoutInformation(models.Model):
self.abacus_order_id = new_abacus_order_id
self.save()
return self
def abacus_address_type(self) -> str:
# always use priv for abacus and CembraPay
return (
self.INVOICE_ADDRESS_ORGANISATION
if self.invoice_address == self.INVOICE_ADDRESS_ORGANISATION
and not self.cembra_byjuno_invoice
else self.INVOICE_ADDRESS_PRIVATE
)
def abacus_use_organisation_data(self) -> bool:
return self.abacus_address_type() == self.INVOICE_ADDRESS_ORGANISATION

View File

@ -163,6 +163,53 @@ class AbacusInvoiceTestCase(TestCase):
assert "<Street>Laupenstrasse</Street>" in customer_xml_content
assert "<Country>CH</Country>" in customer_xml_content
def test_create_customer_xml_byjuno_cembra_with_company_address(self):
_pat = User.objects.get(username="patrizia.huggel@eiger-versicherungen.ch")
_pat.abacus_debitor_number = 60000011
_pat.save()
_ignore_checkout_information = CheckoutInformationFactory(
user=_pat, abacus_order_id=6_000_000_123
)
feuz = User.objects.get(username="andreas.feuz@eiger-versicherungen.ch")
feuz_checkout_info = CheckoutInformationFactory(
user=feuz,
transaction_id="24021508331287484",
first_name="Andreas",
last_name="Feuz",
street="Eggersmatt",
street_number="32",
postal_code="1719",
city="Zumholz",
country_id="CH",
invoice_address="org",
organisation_detail_name="VBV",
organisation_street="Laupenstrasse",
organisation_street_number="10",
organisation_postal_code="3000",
organisation_city="Bern",
organisation_country_id="CH",
cembra_byjuno_invoice=True,
)
feuz_checkout_info.created_at = datetime(2024, 2, 15, 8, 33, 12, 0)
customer_xml_filename, customer_xml_content = create_customer_xml(
checkout_information=feuz_checkout_info
)
print(customer_xml_content)
print(customer_xml_filename)
self.assertEqual("myVBV_debi_60000012.xml", customer_xml_filename)
assert "<CustomerNumber>60000012</CustomerNumber>" in customer_xml_content
assert (
"<Email>andreas.feuz@eiger-versicherungen.ch</Email>"
in customer_xml_content
)
assert "<AddressNumber>60000012</AddressNumber>" in customer_xml_content
assert "<Name>Feuz</Name>" in customer_xml_content
assert "<Text>VBV</Text>" not in customer_xml_content
assert "<Street>Eggersmatt</Street>" in customer_xml_content
def test_render_customer_xml(self):
customer_xml = render_customer_xml(
abacus_debitor_number=60000012,

View File

@ -1,29 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd">
<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;">
<g id="Artboard1" transform="matrix(1,0,0,1.21111,0,0)">
<rect x="0" y="0" width="119.93" height="99" style="fill:none;"/>
<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);"/>
</g>
<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;"/>
<path d="M46.7,66.61L51.7,43.79" style="fill:none;fill-rule:nonzero;stroke:rgb(125,78,42);stroke-width:1px;"/>
<path d="M66.69,53.27L63.26,39.41" style="fill:none;fill-rule:nonzero;stroke:rgb(125,78,42);stroke-width:1px;"/>
<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;"/>
<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;"/>
<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;"/>
<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;"/>
<path d="M48.68,83.86C65.74,84.31 85.27,68.67 95.24,58.7" style="fill-rule:nonzero;"/>
<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>
</g>
</g>
</svg>
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 150 150"><ellipse cx="75" cy="100.42" rx="59.96" ry="34.58" fill="#edf2f6"/><path d="M42.83 107.52c17.06.45 36.59-15.19 46.56-25.16M61.73 102.61l5.01-22.82M81.72 89.27 78.3 75.41M82.22 62.01l-15.64 7.88c-.7.4-.92.56-1.32.91" fill="none" stroke="#7d4e2a" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2.52"/><path fill="none" stroke="#a66635" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2.52" d="m82.69 114.58 4.93-22.45M101.51 102.17l-5.07-20.49"/><path d="m62.67 83.78.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.97l8.88-32.19c.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.22-.18.09c-.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.07L85.56 90.8s-.09.05-.13.07c-1.16.67-2.17 2.21-2.36 3.66l-.14 1.02c-.07.53.08.92.35 1.08L63.02 84.86c-.28-.16-.43-.55-.35-1.08Z" fill="#afc8df"/><path d="M108.62 48.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.07L85.57 90.8s-.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.22-.18.09Z" fill="#97b3cd"/><path d="M83.87 92.45 63.61 80.68c-.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.07ZM76.89 72.69c.49-.58.89-1.29 1.1-2.04l8.88-32.19c.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.04L76.89 72.7Z" fill="#7d95ac"/><path d="M63.71 119.86c17.06.45 36.59-15.19 46.56-25.16M102.36 74.72l-14.9 7.51c-.7.4-.92.56-1.32.91" fill="none" stroke="#a66635" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2.52"/><path fill="none" stroke="#a66635" stroke-linecap="round" stroke-miterlimit="10" stroke-width="2.29" d="m73.26 118.57-20.81-12.01"/></svg>

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB