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 { computed, ref, watch } from "vue";
import { useTranslation } from "i18next-vue"; import { useTranslation } from "i18next-vue";
import _ from "lodash"; import _ from "lodash";
import type { DashboardPersonCourseSessionType } from "@/services/dashboard"; import {
type DashboardPersonCourseSessionType,
exportPersons,
} from "@/services/dashboard";
import { useRouteQuery } from "@vueuse/router"; 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"); log.debug("DashboardPersonsPage created");
@ -28,6 +33,7 @@ type MenuItem = {
}; };
const { t } = useTranslation(); const { t } = useTranslation();
const userStore = useUserStore();
const { loading, dashboardPersons } = useDashboardPersonsDueDates(props.mode); const { loading, dashboardPersons } = useDashboardPersonsDueDates(props.mode);
@ -227,6 +233,32 @@ function personRoleDisplayValue(personCourseSession: DashboardPersonCourseSessio
return ""; 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, () => { watch(selectedCourse, () => {
selectedRegion.value = regions.value[0]; 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> <it-icon-arrow-left class="-ml-1 mr-1 h-5 w-5"></it-icon-arrow-left>
<span class="inline">{{ $t("general.back") }}</span> <span class="inline">{{ $t("general.back") }}</span>
</router-link> </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"> <div class="bg-white px-4 py-2">
<section <section
v-if="filtersVisible" v-if="filtersVisible"

View File

@ -293,7 +293,7 @@ const executePayment = async () => {
{{ formErrors.personal.join(", ") }} {{ formErrors.personal.join(", ") }}
</p> </p>
<section v-if="address.payment_method !== 'cembra_byjuno'"> <section>
<div class="mt-4"> <div class="mt-4">
<button <button
v-if="!withCompanyAddress" 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( export function courseIdForCourseSlug(
dashboardConfigs: DashboardCourseConfigType[], dashboardConfigs: DashboardCourseConfigType[],
courseSlug: string courseSlug: string

View File

@ -26,7 +26,7 @@ function getCurrentDate() {
function verifyExportFileExists(fileName) { function verifyExportFileExists(fileName) {
const downloadsFolder = Cypress.config("downloadsFolder"); const downloadsFolder = Cypress.config("downloadsFolder");
cy.readFile( cy.readFile(
path.join(downloadsFolder, `${fileName}_${getCurrentDate()}.xlsx`) path.join(downloadsFolder, `${fileName}_${getCurrentDate()}.xlsx`),
).should("exist"); ).should("exist");
} }
@ -39,7 +39,7 @@ function testExport(url, fileName) {
describe("dashboardExport.cy.js", () => { describe("dashboardExport.cy.js", () => {
beforeEach(() => { beforeEach(() => {
cy.manageCommand( 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", () => { it("should download the competence elements export", () => {
testExport( testExport(
"/statistic/test-lehrgang/assignment", "/statistic/test-lehrgang/assignment",
"export_kompetenznachweis_elemente" "export_kompetenznachweis_elemente",
); );
}); });
it("should download the feedback export", () => { it("should download the feedback export", () => {
testExport("/statistic/test-lehrgang/feedback", "export_feedback"); testExport("/statistic/test-lehrgang/feedback", "export_feedback");
}); });
it("should download the person export", () => {
testExport("/dashboard/persons", "export_personen");
});
}); });
describe("as trainer", () => { describe("as trainer", () => {
@ -76,12 +80,16 @@ describe("dashboardExport.cy.js", () => {
it("should download the competence elements export", () => { it("should download the competence elements export", () => {
testExport( testExport(
"/statistic/test-lehrgang/assignment", "/statistic/test-lehrgang/assignment",
"export_kompetenznachweis_elemente" "export_kompetenznachweis_elemente",
); );
}); });
it("should download the feedback export", () => { it("should download the feedback export", () => {
testExport("/statistic/test-lehrgang/feedback", "export_feedback"); 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_attendance_as_xsl,
export_competence_elements_as_xsl, export_competence_elements_as_xsl,
export_feedback_as_xsl, export_feedback_as_xsl,
export_persons_as_xsl,
get_dashboard_config, get_dashboard_config,
get_dashboard_due_dates, get_dashboard_due_dates,
get_dashboard_persons, get_dashboard_persons,
@ -143,6 +144,7 @@ urlpatterns = [
path(r"api/dashboard/export/competence_elements/", export_competence_elements_as_xsl, path(r"api/dashboard/export/competence_elements/", export_competence_elements_as_xsl,
name="export_certificate_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/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 # course
path(r"api/course/sessions/", get_course_sessions, name="get_course_sessions"), path(r"api/course/sessions/", get_course_sessions, name="get_course_sessions"),

View File

@ -87,7 +87,7 @@ msgstr ""
msgid "Lehrgang-Seite" msgid "Lehrgang-Seite"
msgstr "" msgstr ""
#: vbv_lernwelt/course/models.py:278 #: vbv_lernwelt/dashboard/person_export.py:116
msgid "Teilnehmer" msgid "Teilnehmer"
msgstr "" msgstr ""
@ -111,7 +111,7 @@ msgstr ""
msgid "Versicherungsvermittler-Lehrgang" msgid "Versicherungsvermittler-Lehrgang"
msgstr "" msgstr ""
#: vbv_lernwelt/course/models.py:351 #: vbv_lernwelt/course/models.py:350
msgid "ÜK-Lehrgang" msgid "ÜK-Lehrgang"
msgstr "" msgstr ""
@ -155,10 +155,30 @@ msgstr ""
msgid "Email" msgid "Email"
msgstr "Email" msgstr "Email"
#: vbv_lernwelt/course_session/services/export_attendance.py:138 #: vbv_lernwelt/course_session/services/export_attendance.py:127
msgid "Lehrvertragsnummer" msgid "Lehrvertragsnummer"
msgstr "" 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 #: vbv_lernwelt/feedback/export.py:19
msgid "export_feedback" msgid "export_feedback"
msgstr "" msgstr ""

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -88,8 +88,9 @@ msgid "Lehrgang-Seite"
msgstr "" msgstr ""
#: vbv_lernwelt/course/models.py:278 #: vbv_lernwelt/course/models.py:278
#: vbv_lernwelt/dashboard/person_export.py:116
msgid "Teilnehmer" msgid "Teilnehmer"
msgstr "" msgstr "Participant"
#: vbv_lernwelt/course/models.py:279 #: vbv_lernwelt/course/models.py:279
msgid "Experte/Trainer" msgid "Experte/Trainer"
@ -120,7 +121,6 @@ msgid "export_anwesenheit"
msgstr "export_presence" msgstr "export_presence"
#: vbv_lernwelt/course_session/services/export_attendance.py:86 #: vbv_lernwelt/course_session/services/export_attendance.py:86
#| msgid "Anwesenheit"
msgid "Optionale Anwesenheit" msgid "Optionale Anwesenheit"
msgstr "Présence facultative" msgstr "Présence facultative"
@ -160,6 +160,26 @@ msgstr "E-mail"
msgid "Lehrvertragsnummer" msgid "Lehrvertragsnummer"
msgstr "Numéro de contrat d'apprentissage" 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 #: vbv_lernwelt/feedback/export.py:19
msgid "export_feedback" msgid "export_feedback"
msgstr "export_feedback" msgstr "export_feedback"

View File

@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \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" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@ -88,8 +88,9 @@ msgid "Lehrgang-Seite"
msgstr "" msgstr ""
#: vbv_lernwelt/course/models.py:278 #: vbv_lernwelt/course/models.py:278
#: vbv_lernwelt/dashboard/person_export.py:116
msgid "Teilnehmer" msgid "Teilnehmer"
msgstr "" msgstr "Partecipante"
#: vbv_lernwelt/course/models.py:279 #: vbv_lernwelt/course/models.py:279
msgid "Experte/Trainer" msgid "Experte/Trainer"
@ -120,7 +121,6 @@ msgid "export_anwesenheit"
msgstr "esportazione_presenza" msgstr "esportazione_presenza"
#: vbv_lernwelt/course_session/services/export_attendance.py:86 #: vbv_lernwelt/course_session/services/export_attendance.py:86
#| msgid "Anwesenheit"
msgid "Optionale Anwesenheit" msgid "Optionale Anwesenheit"
msgstr "Presenza opzionale" msgstr "Presenza opzionale"
@ -160,6 +160,28 @@ msgstr "Email"
msgid "Lehrvertragsnummer" msgid "Lehrvertragsnummer"
msgstr "Numero di contratto di tirocinio" 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 #: vbv_lernwelt/feedback/export.py:19
msgid "export_feedback" msgid "export_feedback"
msgstr "esportazione_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 dataclasses import asdict, dataclass
from datetime import date from datetime import date
from enum import Enum from enum import Enum
from typing import List, Set, Tuple from typing import List, Tuple
from django.db.models import Q from django.db.models import Q
from django.http import HttpResponse from django.http import HttpResponse
@ -24,18 +24,21 @@ from vbv_lernwelt.competence.services import (
query_competence_course_session_edoniq_tests, query_competence_course_session_edoniq_tests,
) )
from vbv_lernwelt.core.models import User from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import ( from vbv_lernwelt.course.models import CourseConfiguration, CourseSessionUser
CourseConfiguration,
CourseSession,
CourseSessionUser,
)
from vbv_lernwelt.course.views import logger from vbv_lernwelt.course.views import logger
from vbv_lernwelt.course_session.services.export_attendance import ( from vbv_lernwelt.course_session.services.export_attendance import (
ATTENDANCE_EXPORT_FILENAME, ATTENDANCE_EXPORT_FILENAME,
export_attendance, export_attendance,
make_export_filename, 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.models import DueDate
from vbv_lernwelt.duedate.serializers import DueDateSerializer from vbv_lernwelt.duedate.serializers import DueDateSerializer
from vbv_lernwelt.feedback.export import ( from vbv_lernwelt.feedback.export import (
@ -71,19 +74,6 @@ class RoleKeyType(Enum):
UNKNOWN_ROLE_KEY = "UnknownRoleKey" 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) @dataclass(frozen=True)
class CourseConfig: class CourseConfig:
course_id: str course_id: str
@ -97,162 +87,6 @@ class CourseConfig:
session_to_continue_id: str | None 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): def _persons_list_add_competence_metrics(persons):
course_session_ids = {cs["id"] for p in persons for cs in p["course_sessions"]} course_session_ids = {cs["id"] for p in persons for cs in p["course_sessions"]}
competence_assignments = query_competence_course_session_assignments( competence_assignments = query_competence_course_session_assignments(
@ -295,7 +129,7 @@ def _persons_list_add_competence_metrics(persons):
@api_view(["GET"]) @api_view(["GET"])
def get_dashboard_persons(request): def get_dashboard_persons(request):
try: 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": if request.GET.get("with_competence_metrics", "") == "true":
persons = _persons_list_add_competence_metrics(persons) 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) cs = course_session_map.get(due_date.course_session_id)
if cs: 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="" cs, my_role=user_role(cs.roles), user_role=""
) )
result_due_dates.append(data) result_due_dates.append(data)
@ -610,6 +444,20 @@ def export_feedback_as_xsl(request):
return _make_excel_response(data, FEEDBACK_EXPORT_FILE_NAME) 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( def _get_permitted_courses_sessions_for_user(
user: User, requested_coursesession_ids: List[str] user: User, requested_coursesession_ids: List[str]
) -> List[CourseSessionWithRoles]: ) -> List[CourseSessionWithRoles]:

View File

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

View File

@ -128,3 +128,15 @@ class CheckoutInformation(models.Model):
self.abacus_order_id = new_abacus_order_id self.abacus_order_id = new_abacus_order_id
self.save() self.save()
return self 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 "<Street>Laupenstrasse</Street>" in customer_xml_content
assert "<Country>CH</Country>" 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): def test_render_customer_xml(self):
customer_xml = render_customer_xml( customer_xml = render_customer_xml(
abacus_debitor_number=60000012, abacus_debitor_number=60000012,

View File

@ -1,29 +1 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?> <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>
<!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>

Before

Width:  |  Height:  |  Size: 7.6 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB