diff --git a/server/vbv_lernwelt/assignment/management/commands/export_assignment_completions.py b/server/vbv_lernwelt/assignment/management/commands/export_assignment_completions.py
new file mode 100644
index 00000000..fc4ed97a
--- /dev/null
+++ b/server/vbv_lernwelt/assignment/management/commands/export_assignment_completions.py
@@ -0,0 +1,18 @@
+import djclick as click
+import structlog
+
+from vbv_lernwelt.assignment.services import export_competence_certificates
+
+logger = structlog.get_logger(__name__)
+
+
+@click.command()
+@click.argument("course_session_id")
+@click.option(
+ "--save-as-file/--no-save-as-file",
+ default=True,
+ help="`save-as-file` to save the file, `no-save-as-file` returns bytes. Default is `save-as-file`.",
+)
+def command(course_session_id, save_as_file):
+ # using the output from call_command was a bit cumbersome, so this is just a wrapper for the actual function
+ export_competence_certificates([course_session_id], save_as_file)
diff --git a/server/vbv_lernwelt/assignment/services.py b/server/vbv_lernwelt/assignment/services.py
index 94a00b49..8bd82860 100644
--- a/server/vbv_lernwelt/assignment/services.py
+++ b/server/vbv_lernwelt/assignment/services.py
@@ -1,6 +1,10 @@
from copy import deepcopy
+from dataclasses import dataclass
+from io import BytesIO
+import structlog
from django.utils import timezone
+from openpyxl import Workbook
from rest_framework import serializers
from wagtail.models import Page
@@ -14,10 +18,38 @@ from vbv_lernwelt.assignment.models import (
)
from vbv_lernwelt.core.models import User
from vbv_lernwelt.core.utils import find_first
-from vbv_lernwelt.course.models import CourseCompletionStatus, CourseSession
+from vbv_lernwelt.course.models import (
+ CourseCompletionStatus,
+ CourseSession,
+ CourseSessionUser,
+)
from vbv_lernwelt.course.services import mark_course_completion
+from vbv_lernwelt.course_session.models import (
+ CourseSessionAssignment,
+ CourseSessionEdoniqTest,
+)
+from vbv_lernwelt.course_session.services.export import (
+ add_user_export_data,
+ add_user_headers,
+ get_ordered_csus_by_course_session,
+ group_by_session_title,
+ make_export_filename,
+ sanitize_sheet_name,
+)
+from vbv_lernwelt.duedate.models import DueDate
+from vbv_lernwelt.learnpath.models import LearningContent
from vbv_lernwelt.notify.services import NotificationService
+logger = structlog.get_logger(__name__)
+
+
+@dataclass
+class CompetenceCertificateElement:
+ assignment: Assignment
+ date: DueDate
+ learning_content: LearningContent
+ course_session: CourseSession
+
def update_assignment_completion(
assignment_user: User,
@@ -67,9 +99,9 @@ def update_assignment_completion(
assignment_user_id=assignment_user.id,
assignment_id=assignment.id,
course_session_id=course_session.id,
- learning_content_page_id=learning_content_page.id
- if learning_content_page
- else None,
+ learning_content_page_id=(
+ learning_content_page.id if learning_content_page else None
+ ),
)
if initialize_completion:
@@ -271,3 +303,192 @@ def _remove_unknown_entries(assignment, completion_data):
key: value for key, value in completion_data.items() if key in input_task_ids
}
return filtered_completion_data
+
+
+def export_competence_certificates(
+ course_session_ids: list[str], save_as_file: bool, circle_ids: list[int] = None
+):
+ if len(course_session_ids) == 0:
+ return
+
+ COMPETENCE_ASSIGNMENT_TYPES = [
+ AssignmentType.CASEWORK.value,
+ AssignmentType.EDONIQ_TEST.value,
+ ]
+
+ wb = Workbook()
+ # remove the first sheet is just easier than keeping track of the active sheet
+ wb.remove(wb.active)
+
+ competence_certificate_elements = _get_competence_certificate_elements(
+ course_session_ids
+ )
+
+ assignemnt_completions = AssignmentCompletion.objects.filter(
+ course_session_id__in=course_session_ids,
+ assignment__assignment_type__in=COMPETENCE_ASSIGNMENT_TYPES,
+ ).order_by("course_session", "assignment")
+
+ # group all by the sessions title {session_id1: [...], session_id2: [...], ...}
+ grouped_cs_users = get_ordered_csus_by_course_session(course_session_ids)
+ grouped_cce = group_by_session_title(competence_certificate_elements)
+ grouped_ac = group_by_session_title(assignemnt_completions)
+
+ # create a sheet for each course session
+ for course_session, cs_users in grouped_cs_users.items():
+ logger.debug(
+ "export_assignment_completion",
+ data={
+ "course_session": course_session,
+ },
+ label="assignment_export",
+ )
+ _create_sheet(
+ wb,
+ course_session,
+ cs_users,
+ grouped_cce[course_session],
+ grouped_ac[course_session],
+ circle_ids,
+ )
+
+ if save_as_file:
+ wb.save(make_export_filename(name="competence_certificate_export"))
+ else:
+ output = BytesIO()
+ wb.save(output)
+
+ output.seek(0)
+ return output.getvalue()
+
+
+def _create_sheet(
+ wb: Workbook,
+ title: str,
+ users: list[CourseSessionUser],
+ competence_certificate_element: list[CompetenceCertificateElement],
+ assignment_completions: list[AssignmentCompletion],
+ circle_ids: list[int],
+):
+ sheet = wb.create_sheet(title=sanitize_sheet_name(title))
+
+ if len(users) == 0:
+ return sheet
+
+ # headers
+ # common user headers, Circle
bestanden, Circle Resultat, ...
+ col_idx = add_user_headers(sheet)
+
+ ordered_assignement_ids = (
+ []
+ ) # keep track of the order of the columns when adding the rows
+ for cse in competence_certificate_element:
+ circle = cse.learning_content.get_circle()
+
+ if circle_ids and circle.id not in circle_ids:
+ continue
+
+ col_prefix = f'Circle "{circle.title}" {cse.learning_content.title} '
+
+ sheet.cell(
+ row=1,
+ column=col_idx,
+ value=f"{col_prefix} bestanden",
+ )
+
+ sheet.cell(
+ row=1,
+ column=col_idx + 1,
+ value=f"{col_prefix} Resultat %",
+ )
+
+ ordered_assignement_ids.append(cse.assignment.id)
+
+ col_idx += 2
+
+ # add rows with user results
+ _add_rows(sheet, users, ordered_assignement_ids, assignment_completions)
+
+ return sheet
+
+
+def _add_rows(
+ sheet,
+ users: list[CourseSessionUser],
+ ordered_assignement_ids,
+ assignment_completions,
+):
+ for row_idx, user in enumerate(users, start=2):
+ col_idx = add_user_export_data(sheet, user, row_idx)
+
+ for assignment_id in ordered_assignement_ids:
+ # get the completion for the user and the assignment
+ user_acs = [
+ ac
+ for ac in assignment_completions
+ if ac.assignment_id == assignment_id and ac.assignment_user == user.user
+ ]
+ user_ac = user_acs[0] if user_acs else None
+
+ if user_ac:
+ status_text = (
+ "Bestanden" if user_ac.evaluation_passed else "Nicht bestanden"
+ )
+ sheet.cell(row=row_idx, column=col_idx, value=status_text)
+ try:
+ sheet.cell(
+ row=row_idx,
+ column=col_idx + 1,
+ value=round(
+ 100
+ * user_ac.evaluation_points
+ / user_ac.evaluation_max_points
+ ),
+ )
+ except (ZeroDivisionError, TypeError):
+ sheet.cell(row=row_idx, column=col_idx + 1, value="Keine Daten")
+
+ else:
+ sheet.cell(row=row_idx, column=col_idx, value="Keine Daten")
+ sheet.cell(row=row_idx, column=col_idx + 1, value="Keine Daten")
+
+ col_idx += 2
+
+
+def _get_competence_certificate_elements(
+ course_session_ids: list[str],
+) -> list[CompetenceCertificateElement]:
+ course_session_assignments = CourseSessionAssignment.objects.filter(
+ course_session__id__in=course_session_ids,
+ learning_content__content_assignment__competence_certificate__isnull=False,
+ ).order_by("course_session", "submission_deadline__start")
+
+ course_session_edoniqtests = CourseSessionEdoniqTest.objects.filter(
+ course_session__id__in=course_session_ids,
+ learning_content__content_assignment__competence_certificate__isnull=False,
+ ).order_by("course_session", "deadline__start")
+
+ cse = [
+ CompetenceCertificateElement(
+ assignment=csa.learning_content.content_assignment,
+ date=csa.submission_deadline,
+ learning_content=csa.learning_content,
+ course_session=csa.course_session,
+ )
+ for csa in course_session_assignments
+ ]
+
+ cse += [
+ CompetenceCertificateElement(
+ assignment=cset.learning_content.content_assignment,
+ date=cset.deadline,
+ learning_content=cset.learning_content,
+ course_session=cset.course_session,
+ )
+ for cset in course_session_edoniqtests
+ ]
+
+ # order by course_session and submission_deadline
+ cse.sort(key=lambda x: (x.course_session.title, x.date.start))
+
+ return cse
diff --git a/server/vbv_lernwelt/course_session/services/export.py b/server/vbv_lernwelt/course_session/services/export.py
index c6703f4d..f5e04466 100644
--- a/server/vbv_lernwelt/course_session/services/export.py
+++ b/server/vbv_lernwelt/course_session/services/export.py
@@ -20,20 +20,13 @@ def export_attendance(
# remove the first sheet is just easier than keeping track of the active sheet
wb.remove(wb.active)
- cs_users = CourseSessionUser.objects.filter(
- course_session_id__in=course_session_ids, role=CourseSessionUser.Role.MEMBER
- ).order_by("course_session", "user__last_name", "user__first_name")
attendance_courses = CourseSessionAttendanceCourse.objects.filter(
course_session_id__in=course_session_ids
).order_by("course_session", "due_date")
- grouped_cs_users = {
- key: list(group)
- for key, group in groupby(
- sorted(cs_users, key=lambda x: x.course_session.title),
- key=lambda x: x.course_session.title,
- )
- }
+ grouped_cs_users = get_ordered_csus_by_course_session(course_session_ids)
+
+ # create dict with course_session as key and list of attendance_courses as value. Easier to access in the loop
grouped_attendance_course = {
key: list(group)
for key, group in groupby(
@@ -42,6 +35,7 @@ def export_attendance(
)
}
+ # create a sheet for each course_session
for course_session, cs_users in grouped_cs_users.items():
logger.debug(
"export_attendance_for_course_session",
@@ -81,14 +75,8 @@ def _create_sheet(
return sheet
# headers
- # firstname, lastname, email, lehrvertragsnummer, , status , ..
- # todo: translate headers
- sheet.cell(row=1, column=1, value="Vorname")
- sheet.cell(row=1, column=2, value="Nachname")
- sheet.cell(row=1, column=3, value="Email")
- sheet.cell(row=1, column=4, value="Lehrvertragsnummer")
-
- col_idx = 5
+ # common user headers..., , status , ..
+ col_idx = add_user_headers(sheet)
attendance_data = {}
for course in attendance_courses:
circle = course.get_circle()
@@ -105,6 +93,7 @@ def _create_sheet(
col_idx += 1
+ # add rows with user data
_add_rows(sheet, users, attendance_data)
return sheet
@@ -112,16 +101,7 @@ def _create_sheet(
def _add_rows(sheet, users: list[CourseSessionUser], attendance_data):
for row_idx, user in enumerate(users, start=2):
- sheet.cell(row=row_idx, column=1, value=user.user.first_name)
- sheet.cell(row=row_idx, column=2, value=user.user.last_name)
- sheet.cell(row=row_idx, column=3, value=user.user.email)
- sheet.cell(
- row=row_idx,
- column=4,
- value=user.user.additional_json_data.get("Lehrvertragsnummer", ""),
- )
-
- col_idx = 5
+ col_idx = add_user_export_data(sheet, user, row_idx)
for key, user_dict_map in attendance_data.items():
user_dict = user_dict_map.get(str(user.user.id), {})
status = user_dict.get("status", "") if user_dict else ""
@@ -130,6 +110,48 @@ def _add_rows(sheet, users: list[CourseSessionUser], attendance_data):
col_idx += 1
+def add_user_headers(sheet):
+ # todo: translate headers
+ sheet.cell(row=1, column=1, value="Vorname")
+ sheet.cell(row=1, column=2, value="Nachname")
+ sheet.cell(row=1, column=3, value="Email")
+ sheet.cell(row=1, column=4, value="Lehrvertragsnummer")
+
+ return 5 # return the next column index
+
+
+def add_user_export_data(sheet, user: CourseSessionUser, row_idx: int) -> int:
+ sheet.cell(row=row_idx, column=1, value=user.user.first_name)
+ sheet.cell(row=row_idx, column=2, value=user.user.last_name)
+ sheet.cell(row=row_idx, column=3, value=user.user.email)
+ sheet.cell(
+ row=row_idx,
+ column=4,
+ value=user.user.additional_json_data.get("Lehrvertragsnummer", ""),
+ )
+
+ return 5 # return the next column index
+
+
+def get_ordered_csus_by_course_session(course_session_ids: list[str]):
+ csus = CourseSessionUser.objects.filter(
+ course_session_id__in=course_session_ids, role=CourseSessionUser.Role.MEMBER
+ ).order_by("course_session", "user__last_name", "user__first_name")
+ return group_by_session_title(
+ sorted(csus, key=lambda x: x.course_session.title),
+ )
+
+
+def group_by_session_title(items):
+ return {
+ key: list(group)
+ for key, group in groupby(
+ sorted(items, key=lambda x: x.course_session.title),
+ key=lambda x: x.course_session.title,
+ )
+ }
+
+
def make_export_filename(name: str = "attendance_export"):
today_date = datetime.today().strftime("%Y-%m-%d")
return f"{name}_{today_date}.xlsx"
diff --git a/server/vbv_lernwelt/course_session_group/admin.py b/server/vbv_lernwelt/course_session_group/admin.py
index 5bda3780..f880cfa3 100644
--- a/server/vbv_lernwelt/course_session_group/admin.py
+++ b/server/vbv_lernwelt/course_session_group/admin.py
@@ -4,5 +4,4 @@ from vbv_lernwelt.course_session_group.models import CourseSessionGroup
@admin.register(CourseSessionGroup)
-class CourseSessionAssignmentAdmin(admin.ModelAdmin):
- ...
+class CourseSessionAssignmentAdmin(admin.ModelAdmin): ...
diff --git a/server/vbv_lernwelt/dashboard/views.py b/server/vbv_lernwelt/dashboard/views.py
index 2e1eb012..8ee1a51d 100644
--- a/server/vbv_lernwelt/dashboard/views.py
+++ b/server/vbv_lernwelt/dashboard/views.py
@@ -433,9 +433,9 @@ def get_course_config(
is_mentor=is_mentor,
widgets=get_widgets_for_course(role_key, is_uk, is_vv, is_mentor),
has_preview=has_preview(role_key),
- session_to_continue_id=str(session_to_continue.id)
- if session_to_continue
- else None,
+ session_to_continue_id=(
+ str(session_to_continue.id) if session_to_continue else None
+ ),
)
)
diff --git a/server/vbv_lernwelt/edoniq_test/views.py b/server/vbv_lernwelt/edoniq_test/views.py
index 00d8ff7b..e3a71ec2 100644
--- a/server/vbv_lernwelt/edoniq_test/views.py
+++ b/server/vbv_lernwelt/edoniq_test/views.py
@@ -153,9 +153,9 @@ def fetch_course_session_all_users(courses: List[int], excluded_domains=None):
def generate_export_response(cs_users: List[User]) -> HttpResponse:
response = HttpResponse(content_type="text/csv; charset=utf-8")
- response[
- "Content-Disposition"
- ] = f"attachment; filename=edoniq_user_export_{date.today().strftime('%Y%m%d')}.csv"
+ response["Content-Disposition"] = (
+ f"attachment; filename=edoniq_user_export_{date.today().strftime('%Y%m%d')}.csv"
+ )
response.write("\ufeff".encode("utf8")) # UTF-8 BOM
diff --git a/server/vbv_lernwelt/feedback/services.py b/server/vbv_lernwelt/feedback/services.py
index e528ad96..e240d9f4 100644
--- a/server/vbv_lernwelt/feedback/services.py
+++ b/server/vbv_lernwelt/feedback/services.py
@@ -86,9 +86,11 @@ def update_feedback_response(
initial_data = initial_data_for_feedback_page(learning_content_feedback_page)
merged_data = initial_data | {
- key: updated_data[key]
- if updated_data.get(key, "") != ""
- else original_data.get(key)
+ key: (
+ updated_data[key]
+ if updated_data.get(key, "") != ""
+ else original_data.get(key)
+ )
for key in initial_data.keys()
}
@@ -249,9 +251,9 @@ def _handle_feedback_export_action(course_seesions, file_name):
response = HttpResponse(
content_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"
)
- response[
- "Content-Disposition"
- ] = f"attachment; filename={make_export_filename(file_name)}"
+ response["Content-Disposition"] = (
+ f"attachment; filename={make_export_filename(file_name)}"
+ )
response.write(excel_bytes)
return response
diff --git a/server/vbv_lernwelt/learning_mentor/content/self_evaluation_feedback.py b/server/vbv_lernwelt/learning_mentor/content/self_evaluation_feedback.py
index 959f6f41..da451959 100644
--- a/server/vbv_lernwelt/learning_mentor/content/self_evaluation_feedback.py
+++ b/server/vbv_lernwelt/learning_mentor/content/self_evaluation_feedback.py
@@ -70,9 +70,11 @@ def get_self_feedback_evaluation(
MentorAssignmentCompletion(
# feedback_submitted as seen from the perspective of the evaluation user (feedback provider)
# means that the feedback has been evaluated by the feedback provider, hence the status is EVALUATED
- status=MentorCompletionStatus.EVALUATED
- if f.feedback_submitted
- else MentorCompletionStatus.SUBMITTED,
+ status=(
+ MentorCompletionStatus.EVALUATED
+ if f.feedback_submitted
+ else MentorCompletionStatus.SUBMITTED
+ ),
user_id=f.feedback_requester_user.id,
last_name=f.feedback_requester_user.last_name,
url=f"/course/{course.slug}/cockpit/mentor/self-evaluation-feedback/{f.learning_unit.id}",
diff --git a/server/vbv_lernwelt/notify/tests/test_service.py b/server/vbv_lernwelt/notify/tests/test_service.py
index 6d8af677..1feb4bad 100644
--- a/server/vbv_lernwelt/notify/tests/test_service.py
+++ b/server/vbv_lernwelt/notify/tests/test_service.py
@@ -65,9 +65,9 @@ class TestNotificationService(TestCase):
self.assertFalse(notification.emailed)
def test_send_notification_with_email(self):
- self.recipient.additional_json_data[
- "email_notification_categories"
- ] = json.dumps(["USER_INTERACTION"])
+ self.recipient.additional_json_data["email_notification_categories"] = (
+ json.dumps(["USER_INTERACTION"])
+ )
self.recipient.save()
verb = "Anne hat deinen Auftrag bewertet"
@@ -146,9 +146,9 @@ class TestNotificationService(TestCase):
self.assertFalse(notification.emailed)
# when the email was not sent, yet it will still send it afterwards...
- self.recipient.additional_json_data[
- "email_notification_categories"
- ] = json.dumps(["USER_INTERACTION"])
+ self.recipient.additional_json_data["email_notification_categories"] = (
+ json.dumps(["USER_INTERACTION"])
+ )
self.recipient.save()
result = self.notification_service._send_notification(
@@ -188,9 +188,9 @@ class TestNotificationService(TestCase):
self.assertFalse(self._has_sent_emails())
# Assert mail is sent if corresponding email notification type is enabled
- self.recipient.additional_json_data[
- "email_notification_categories"
- ] = json.dumps(["USER_INTERACTION"])
+ self.recipient.additional_json_data["email_notification_categories"] = (
+ json.dumps(["USER_INTERACTION"])
+ )
self.recipient.save()
self.notification_service._send_notification(
sender=self.sender,
diff --git a/server/vbv_lernwelt/self_evaluation_feedback/serializers.py b/server/vbv_lernwelt/self_evaluation_feedback/serializers.py
index fd24d363..0c73c3dc 100644
--- a/server/vbv_lernwelt/self_evaluation_feedback/serializers.py
+++ b/server/vbv_lernwelt/self_evaluation_feedback/serializers.py
@@ -39,9 +39,9 @@ class SelfEvaluationFeedbackSerializer(serializers.ModelSerializer):
return obj.learning_unit.get_circle().title
def get_criteria(self, obj):
- performance_criteria: List[
- PerformanceCriteria
- ] = obj.learning_unit.performancecriteria_set.all()
+ performance_criteria: List[PerformanceCriteria] = (
+ obj.learning_unit.performancecriteria_set.all()
+ )
criteria = []
diff --git a/server/vbv_lernwelt/shop/invoice/abacus.py b/server/vbv_lernwelt/shop/invoice/abacus.py
index a92b1056..3ab2143b 100644
--- a/server/vbv_lernwelt/shop/invoice/abacus.py
+++ b/server/vbv_lernwelt/shop/invoice/abacus.py
@@ -67,15 +67,15 @@ class AbacusInvoiceCreator(InvoiceCreator):
)
SubElement(sales_order_header_fields, "CustomerNumber").text = customer_number
- SubElement(
- sales_order_header_fields, "PurchaseOrderDate"
- ).text = order_date.isoformat()
- SubElement(
- sales_order_header_fields, "DeliveryDate"
- ).text = order_date.isoformat()
- SubElement(
- sales_order_header_fields, "ReferencePurchaseOrder"
- ).text = reference_purchase_order
+ SubElement(sales_order_header_fields, "PurchaseOrderDate").text = (
+ order_date.isoformat()
+ )
+ SubElement(sales_order_header_fields, "DeliveryDate").text = (
+ order_date.isoformat()
+ )
+ SubElement(sales_order_header_fields, "ReferencePurchaseOrder").text = (
+ reference_purchase_order
+ )
SubElement(sales_order_header_fields, "UnicId").text = unic_id
for index, item in enumerate(items, start=1):