From 278d65990513c7899b4dff9fe1bbcfdeb9deca62 Mon Sep 17 00:00:00 2001 From: Reto Aebersold Date: Mon, 4 Dec 2023 10:06:19 +0100 Subject: [PATCH 01/61] wip: learning mentor --- .../pages/cockpit/vv/VVCockpitParentPage.vue | 11 +++++ server/config/settings/base.py | 1 + server/config/urls.py | 5 ++ .../assignment/graphql/mutations.py | 4 +- .../vbv_lernwelt/assignment/graphql/types.py | 4 +- server/vbv_lernwelt/assignment/views.py | 4 +- server/vbv_lernwelt/iam/permissions.py | 17 +++++++ .../vbv_lernwelt/learning_mentor/__init__.py | 0 server/vbv_lernwelt/learning_mentor/admin.py | 10 ++++ server/vbv_lernwelt/learning_mentor/apps.py | 6 +++ .../migrations/0001_initial.py | 42 ++++++++++++++++ .../learning_mentor/migrations/__init__.py | 0 server/vbv_lernwelt/learning_mentor/models.py | 13 +++++ .../learning_mentor/tests/__init__.py | 0 .../learning_mentor/tests/test_api.py | 26 ++++++++++ server/vbv_lernwelt/learning_mentor/views.py | 48 +++++++++++++++++++ 16 files changed, 185 insertions(+), 6 deletions(-) create mode 100644 client/src/pages/cockpit/vv/VVCockpitParentPage.vue create mode 100644 server/vbv_lernwelt/learning_mentor/__init__.py create mode 100644 server/vbv_lernwelt/learning_mentor/admin.py create mode 100644 server/vbv_lernwelt/learning_mentor/apps.py create mode 100644 server/vbv_lernwelt/learning_mentor/migrations/0001_initial.py create mode 100644 server/vbv_lernwelt/learning_mentor/migrations/__init__.py create mode 100644 server/vbv_lernwelt/learning_mentor/models.py create mode 100644 server/vbv_lernwelt/learning_mentor/tests/__init__.py create mode 100644 server/vbv_lernwelt/learning_mentor/tests/test_api.py create mode 100644 server/vbv_lernwelt/learning_mentor/views.py diff --git a/client/src/pages/cockpit/vv/VVCockpitParentPage.vue b/client/src/pages/cockpit/vv/VVCockpitParentPage.vue new file mode 100644 index 00000000..ed872e95 --- /dev/null +++ b/client/src/pages/cockpit/vv/VVCockpitParentPage.vue @@ -0,0 +1,11 @@ + + + diff --git a/server/config/settings/base.py b/server/config/settings/base.py index 7e80cd01..8ead2757 100644 --- a/server/config/settings/base.py +++ b/server/config/settings/base.py @@ -130,6 +130,7 @@ LOCAL_APPS = [ "vbv_lernwelt.importer", "vbv_lernwelt.edoniq_test", "vbv_lernwelt.course_session_group", + "vbv_lernwelt.learning_mentor", ] # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS diff --git a/server/config/urls.py b/server/config/urls.py index 59544756..87376ca5 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -53,6 +53,7 @@ from vbv_lernwelt.importer.views import ( coursesessions_trainers_import, t2l_sync, ) +from vbv_lernwelt.learning_mentor.views import mentor_summary from vbv_lernwelt.notify.views import email_notification_settings from wagtail import urls as wagtail_urls from wagtail.admin import urls as wagtailadmin_urls @@ -125,6 +126,10 @@ urlpatterns = [ request_course_completion_for_user, name="request_course_completion_for_user"), + path(r"api/mentor//", + mentor_summary, + name="mentor_summary"), + # assignment path( r"api/assignment///status/", diff --git a/server/vbv_lernwelt/assignment/graphql/mutations.py b/server/vbv_lernwelt/assignment/graphql/mutations.py index 2d9394ca..c7540861 100644 --- a/server/vbv_lernwelt/assignment/graphql/mutations.py +++ b/server/vbv_lernwelt/assignment/graphql/mutations.py @@ -10,7 +10,7 @@ from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletionStatu from vbv_lernwelt.assignment.services import update_assignment_completion from vbv_lernwelt.core.models import User from vbv_lernwelt.course.models import CourseSession, CourseSessionUser -from vbv_lernwelt.iam.permissions import has_course_access, is_course_session_expert +from vbv_lernwelt.iam.permissions import can_evaluate_assignments, has_course_access logger = structlog.get_logger(__name__) @@ -92,7 +92,7 @@ class AssignmentCompletionMutation(graphene.Mutation): AssignmentCompletionStatus.EVALUATION_SUBMITTED, AssignmentCompletionStatus.EVALUATION_IN_PROGRESS, ): - if not is_course_session_expert(info.context.user, course_session_id): + if not can_evaluate_assignments(info.context.user, course_session_id): raise PermissionDenied() evaluation_data = { diff --git a/server/vbv_lernwelt/assignment/graphql/types.py b/server/vbv_lernwelt/assignment/graphql/types.py index 021a2226..c3c933e7 100644 --- a/server/vbv_lernwelt/assignment/graphql/types.py +++ b/server/vbv_lernwelt/assignment/graphql/types.py @@ -7,7 +7,7 @@ from vbv_lernwelt.assignment.models import Assignment, AssignmentCompletion from vbv_lernwelt.core.graphql.types import JSONStreamField from vbv_lernwelt.course.graphql.interfaces import CoursePageInterface from vbv_lernwelt.course.models import CourseSession -from vbv_lernwelt.iam.permissions import has_course_access, is_course_session_expert +from vbv_lernwelt.iam.permissions import can_evaluate_assignments, has_course_access from vbv_lernwelt.learnpath.graphql.types import LearningContentInterface @@ -103,7 +103,7 @@ def resolve_assignment_completion( if assignment_user_id is None: assignment_user_id = info.context.user.id - if str(assignment_user_id) == str(info.context.user.id) or is_course_session_expert( + if str(assignment_user_id) == str(info.context.user.id) or can_evaluate_assignments( info.context.user, course_session_id ): course_id = CourseSession.objects.get(id=course_session_id).course_id diff --git a/server/vbv_lernwelt/assignment/views.py b/server/vbv_lernwelt/assignment/views.py index 640a64ab..7b55be7d 100644 --- a/server/vbv_lernwelt/assignment/views.py +++ b/server/vbv_lernwelt/assignment/views.py @@ -4,7 +4,7 @@ from rest_framework.exceptions import PermissionDenied from rest_framework.response import Response from vbv_lernwelt.assignment.models import AssignmentCompletion -from vbv_lernwelt.iam.permissions import is_course_session_expert +from vbv_lernwelt.iam.permissions import can_evaluate_assignments logger = structlog.get_logger(__name__) @@ -12,7 +12,7 @@ logger = structlog.get_logger(__name__) @api_view(["GET"]) def request_assignment_completion_status(request, assignment_id, course_session_id): # TODO quickfix before GraphQL... - if is_course_session_expert(request.user, course_session_id): + if can_evaluate_assignments(request.user, course_session_id): qs = AssignmentCompletion.objects.filter( course_session_id=course_session_id, assignment_id=assignment_id, diff --git a/server/vbv_lernwelt/iam/permissions.py b/server/vbv_lernwelt/iam/permissions.py index a572f21d..3541cfe6 100644 --- a/server/vbv_lernwelt/iam/permissions.py +++ b/server/vbv_lernwelt/iam/permissions.py @@ -48,6 +48,23 @@ def is_course_session_expert(user, course_session_id: int): return is_supervisor or is_expert +def can_evaluate_assignments(user, course_session_id: int): + if user.is_superuser: + return True + + is_supervisor = CourseSessionGroup.objects.filter( + supervisor=user, course_session__id=course_session_id + ).exists() + + is_expert = CourseSessionUser.objects.filter( + course_session_id=course_session_id, + user=user, + role=CourseSessionUser.Role.EXPERT, + ).exists() + + return is_supervisor or is_expert + + def course_sessions_for_user_qs(user): if user.is_superuser: return CourseSession.objects.all() diff --git a/server/vbv_lernwelt/learning_mentor/__init__.py b/server/vbv_lernwelt/learning_mentor/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/learning_mentor/admin.py b/server/vbv_lernwelt/learning_mentor/admin.py new file mode 100644 index 00000000..6fb7474e --- /dev/null +++ b/server/vbv_lernwelt/learning_mentor/admin.py @@ -0,0 +1,10 @@ +from django.contrib import admin + +from vbv_lernwelt.learning_mentor.models import LearningMentor + + +@admin.register(LearningMentor) +class LearningMentorAdmin(admin.ModelAdmin): + list_display = [ + "mentor_email", + ] diff --git a/server/vbv_lernwelt/learning_mentor/apps.py b/server/vbv_lernwelt/learning_mentor/apps.py new file mode 100644 index 00000000..eba5c3d6 --- /dev/null +++ b/server/vbv_lernwelt/learning_mentor/apps.py @@ -0,0 +1,6 @@ +from django.apps import AppConfig + + +class LearningMentorConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "vbv_lernwelt.learning_mentor" diff --git a/server/vbv_lernwelt/learning_mentor/migrations/0001_initial.py b/server/vbv_lernwelt/learning_mentor/migrations/0001_initial.py new file mode 100644 index 00000000..ef477003 --- /dev/null +++ b/server/vbv_lernwelt/learning_mentor/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 3.2.20 on 2023-12-01 10:11 + +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name="LearningMentor", + fields=[ + ( + "mentor_email", + models.EmailField( + max_length=254, primary_key=True, serialize=False + ), + ), + ( + "mentor", + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to=settings.AUTH_USER_MODEL, + ), + ), + ( + "students", + models.ManyToManyField( + related_name="students", to=settings.AUTH_USER_MODEL + ), + ), + ], + ), + ] diff --git a/server/vbv_lernwelt/learning_mentor/migrations/__init__.py b/server/vbv_lernwelt/learning_mentor/migrations/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/learning_mentor/models.py b/server/vbv_lernwelt/learning_mentor/models.py new file mode 100644 index 00000000..5b6c51ed --- /dev/null +++ b/server/vbv_lernwelt/learning_mentor/models.py @@ -0,0 +1,13 @@ +from django.db import models + +from vbv_lernwelt.core.models import User + + +class LearningMentor(models.Model): + mentor_email = models.EmailField(primary_key=True) + mentor = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + + students = models.ManyToManyField(User, related_name="students") + + def __str__(self): + return self.mentor_email diff --git a/server/vbv_lernwelt/learning_mentor/tests/__init__.py b/server/vbv_lernwelt/learning_mentor/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/learning_mentor/tests/test_api.py b/server/vbv_lernwelt/learning_mentor/tests/test_api.py new file mode 100644 index 00000000..b4b7245b --- /dev/null +++ b/server/vbv_lernwelt/learning_mentor/tests/test_api.py @@ -0,0 +1,26 @@ +from django.urls import reverse +from rest_framework.test import APITestCase + +from vbv_lernwelt.course.creators.test_utils import create_course, create_course_session + + +class LearningMentorAPITest(APITestCase): + # def setUp(self) -> None: + # create_default_users() + # create_test_course(with_sessions=True) + # + # self.mentor = User.objects.get(username="expert-vv.expert1@eiger-versicherungen.ch") + + def test_api(self) -> None: + # GIVEN + course, course_page = create_course("Test Course") + course_session = create_course_session(course=course, title="Test VV") + + # self.client.force_login(self.mentor) + url = reverse("mentor_summary", kwargs={"course_session_id": course_session.id}) + + # WHEN + response = self.client.get(url) + + # THEN + # self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/server/vbv_lernwelt/learning_mentor/views.py b/server/vbv_lernwelt/learning_mentor/views.py new file mode 100644 index 00000000..9ca77589 --- /dev/null +++ b/server/vbv_lernwelt/learning_mentor/views.py @@ -0,0 +1,48 @@ +from rest_framework.decorators import api_view +from rest_framework.response import Response + +from vbv_lernwelt.course.models import CourseSession +from vbv_lernwelt.feedback.models import FeedbackResponse +from vbv_lernwelt.feedback.utils import feedback_users +from vbv_lernwelt.learnpath.models import Circle + + +def get_practical_assignment(): + pass + + +def get_feedbacks(course_session: CourseSession): + circle_feedbacks = [] + + fbs = FeedbackResponse.objects.filter( + submitted=True, + course_session=course_session, + feedback_user__in=feedback_users(course_session.id), + ) + + +def get_all_feedbacks(course_session: CourseSession): + circle_feedbacks = [] + + circles = ( + course_session.course.get_learning_path() + .get_descendants() + .live() + .specific() + .exact_type(Circle) + ) + + print(circles) + + +@api_view(["GET"]) +def mentor_summary(request, course_session_id: int): + if not request.user.is_authenticated: + return Response(status=403) + + if request.method == "GET": + course_session = CourseSession.objects.get(id=course_session_id) + + get_all_feedbacks(course_session) + + return Response({}) From 669dfdd7c1cd087ade57dd0862e5b0bde785c730 Mon Sep 17 00:00:00 2001 From: Reto Aebersold Date: Wed, 6 Dec 2023 10:56:08 +0100 Subject: [PATCH 02/61] feat: praxis assignments --- .../migrations/0001_initial.py | 7 +- server/vbv_lernwelt/learning_mentor/models.py | 11 +- .../learning_mentor/tests/test_assignments.py | 76 ++++++++++ server/vbv_lernwelt/learning_mentor/views.py | 140 +++++++++++++++--- 4 files changed, 207 insertions(+), 27 deletions(-) create mode 100644 server/vbv_lernwelt/learning_mentor/tests/test_assignments.py diff --git a/server/vbv_lernwelt/learning_mentor/migrations/0001_initial.py b/server/vbv_lernwelt/learning_mentor/migrations/0001_initial.py index ef477003..7ca498da 100644 --- a/server/vbv_lernwelt/learning_mentor/migrations/0001_initial.py +++ b/server/vbv_lernwelt/learning_mentor/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.20 on 2023-12-01 10:11 +# Generated by Django 3.2.20 on 2023-12-06 09:32 import django.db.models.deletion from django.conf import settings @@ -9,6 +9,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ + ("course", "0005_course_enable_circle_documents"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] @@ -24,7 +25,7 @@ class Migration(migrations.Migration): ), ( "mentor", - models.ForeignKey( + models.OneToOneField( blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, @@ -34,7 +35,7 @@ class Migration(migrations.Migration): ( "students", models.ManyToManyField( - related_name="students", to=settings.AUTH_USER_MODEL + related_name="students", to="course.CourseSessionUser" ), ), ], diff --git a/server/vbv_lernwelt/learning_mentor/models.py b/server/vbv_lernwelt/learning_mentor/models.py index 5b6c51ed..933ff893 100644 --- a/server/vbv_lernwelt/learning_mentor/models.py +++ b/server/vbv_lernwelt/learning_mentor/models.py @@ -1,13 +1,20 @@ from django.db import models from vbv_lernwelt.core.models import User +from vbv_lernwelt.course.models import CourseSessionUser class LearningMentor(models.Model): mentor_email = models.EmailField(primary_key=True) - mentor = models.ForeignKey(User, on_delete=models.SET_NULL, null=True, blank=True) + mentor = models.OneToOneField( + User, on_delete=models.SET_NULL, null=True, blank=True + ) - students = models.ManyToManyField(User, related_name="students") + students = models.ManyToManyField(CourseSessionUser, related_name="students") def __str__(self): return self.mentor_email + + @property + def course_sessions(self): + return self.students.values_list("course_session", flat=True).distinct() diff --git a/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py b/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py new file mode 100644 index 00000000..8694ff3b --- /dev/null +++ b/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py @@ -0,0 +1,76 @@ +from django.test import TestCase + +from vbv_lernwelt.assignment.models import ( + AssignmentCompletion, + AssignmentCompletionStatus, + AssignmentType, +) +from vbv_lernwelt.course.creators.test_utils import ( + create_assignment, + create_circle, + create_course, + create_course_session, + create_user, +) +from vbv_lernwelt.learning_mentor.views import ( + CompletionStatus, + get_assignment_completions, +) + + +class AttendanceServicesTestCase(TestCase): + def setUp(self): + self.user1 = create_user("Alpha") + self.user2 = create_user("Beta") + self.user3 = create_user("Kappa") + self.user4 = create_user("Gamma") + + self.course, self.course_page = create_course("Test Course") + self.course_session = create_course_session(course=self.course, title=":)") + self.circle, _ = create_circle(title="Circle", course_page=self.course_page) + + self.assignment = create_assignment( + course=self.course, assignment_type=AssignmentType.CASEWORK + ) + + AssignmentCompletion.objects.create( + assignment_user=self.user1, + course_session=self.course_session, + assignment=self.assignment, + completion_status=AssignmentCompletionStatus.EVALUATION_SUBMITTED.value, + ) + AssignmentCompletion.objects.create( + assignment_user=self.user2, + course_session=self.course_session, + assignment=self.assignment, + completion_status=AssignmentCompletionStatus.SUBMITTED.value, + ) + AssignmentCompletion.objects.create( + assignment_user=self.user3, + course_session=self.course_session, + assignment=self.assignment, + completion_status=AssignmentCompletionStatus.IN_PROGRESS.value, + ) + + def test_assignments(self): + # GIVEN + participants = [self.user1, self.user2, self.user3, self.user4] + + # WHEN + results = get_assignment_completions( + self.course_session, self.assignment, participants + ) + + # THEN + expected_order = ["Beta", "Alpha", "Gamma", "Kappa"] + expected_statuses = { + "Alpha": CompletionStatus.EVALUATED, # user1 + "Beta": CompletionStatus.SUBMITTED, # user2 + "Gamma": CompletionStatus.PENDING, # user4 (no AssignmentCompletion) + "Kappa": CompletionStatus.PENDING, # user3 (IN_PROGRESS should be PENDING) + } + + self.assertEqual(len(results), len(participants)) + for i, result in enumerate(results): + self.assertEqual(result.last_name, expected_order[i]) + self.assertEqual(result.status, expected_statuses[result.last_name]) diff --git a/server/vbv_lernwelt/learning_mentor/views.py b/server/vbv_lernwelt/learning_mentor/views.py index 9ca77589..c55eecc2 100644 --- a/server/vbv_lernwelt/learning_mentor/views.py +++ b/server/vbv_lernwelt/learning_mentor/views.py @@ -1,38 +1,127 @@ +from dataclasses import dataclass +from enum import Enum +from typing import List + from rest_framework.decorators import api_view from rest_framework.response import Response +from vbv_lernwelt.assignment.models import ( + Assignment, + AssignmentCompletion, + AssignmentCompletionStatus, + AssignmentType, +) +from vbv_lernwelt.core.models import User +from vbv_lernwelt.core.serializers import UserSerializer from vbv_lernwelt.course.models import CourseSession -from vbv_lernwelt.feedback.models import FeedbackResponse -from vbv_lernwelt.feedback.utils import feedback_users -from vbv_lernwelt.learnpath.models import Circle +from vbv_lernwelt.course_session.models import CourseSessionAssignment +from vbv_lernwelt.learning_mentor.models import LearningMentor -def get_practical_assignment(): - pass +class CompletionStatus(str, Enum): + PENDING = "PENDING" + SUBMITTED = "SUBMITTED" + EVALUATED = "EVALUATED" -def get_feedbacks(course_session: CourseSession): - circle_feedbacks = [] +@dataclass +class PraxisAssignmentCompletion: + status: CompletionStatus + user_id: str + last_name: str - fbs = FeedbackResponse.objects.filter( - submitted=True, + +@dataclass +class PraxisAssignmentStatus: + id: str + title: str + circle_id: str + completions: List[PraxisAssignmentCompletion] + + +def get_assignment_completions( + course_session: CourseSession, assignment: Assignment, participants: List[User] +) -> List[PraxisAssignmentCompletion]: + evaluation_results = AssignmentCompletion.objects.filter( + assignment_user__in=participants, course_session=course_session, - feedback_user__in=feedback_users(course_session.id), + assignment=assignment, + ).values("completion_status", "assignment_user__last_name", "assignment_user") + + user_status_map = {} + for result in evaluation_results: + completion_status = result["completion_status"] + + if completion_status == AssignmentCompletionStatus.EVALUATION_SUBMITTED.value: + status = CompletionStatus.EVALUATED + elif completion_status in [ + AssignmentCompletionStatus.SUBMITTED.value, + AssignmentCompletionStatus.EVALUATION_IN_PROGRESS.value, + ]: + status = CompletionStatus.SUBMITTED + else: + status = CompletionStatus.PENDING + + user_status_map[result["assignment_user"]] = ( + status, + result["assignment_user__last_name"], + ) + + status_priority = { + CompletionStatus.SUBMITTED: 1, + CompletionStatus.EVALUATED: 2, + CompletionStatus.PENDING: 3, + } + + sorted_participants = sorted( + participants, + key=lambda u: ( + status_priority.get( + user_status_map.get(u.id, (CompletionStatus.PENDING, ""))[0] + ), + user_status_map.get(u.id, ("", u.last_name))[1], + ), ) + return [ + PraxisAssignmentCompletion( + status=user_status_map.get( + user.id, (CompletionStatus.PENDING, user.last_name) + )[0], + user_id=user.id, + last_name=user.last_name, + ) + for user in sorted_participants + ] -def get_all_feedbacks(course_session: CourseSession): - circle_feedbacks = [] - circles = ( - course_session.course.get_learning_path() - .get_descendants() - .live() - .specific() - .exact_type(Circle) - ) +def get_praxis_assignments( + course_session: CourseSession, participants: List[User] +) -> List[PraxisAssignmentStatus]: + records = [] - print(circles) + for course_session_assignment in CourseSessionAssignment.objects.filter( + course_session=course_session, + learning_content__content_assignment__assignment_type__in=[ + # TODO: Switch to PRAXIS_ASSIGNMENT + AssignmentType.CASEWORK.value, + ], + ): + learning_content = course_session_assignment.learning_content + records.append( + PraxisAssignmentStatus( + id=course_session_assignment.id, + title=learning_content.content_assignment.title, + circle_id=learning_content.get_circle().id, + completions=get_assignment_completions( + course_session=course_session, + assignment=learning_content.content_assignment, + participants=participants, + ), + ) + ) + + return records @api_view(["GET"]) @@ -43,6 +132,13 @@ def mentor_summary(request, course_session_id: int): if request.method == "GET": course_session = CourseSession.objects.get(id=course_session_id) - get_all_feedbacks(course_session) + participants = LearningMentor.objects.filter(mentor=request.user) - return Response({}) + return Response( + { + "participants": [UserSerializer(s).data for s in participants], + "praxis_assignments": get_praxis_assignments( + course_session, participants + ), + } + ) From 80cc83cde02a55207023ce5bc7aab3f681ae082f Mon Sep 17 00:00:00 2001 From: Reto Aebersold Date: Thu, 7 Dec 2023 08:41:45 +0100 Subject: [PATCH 03/61] feat: praxis assignments --- server/vbv_lernwelt/iam/permissions.py | 10 +++++- .../learning_mentor/tests/test_assignments.py | 26 +++++++++++++-- server/vbv_lernwelt/learning_mentor/views.py | 33 +++++++++++++------ 3 files changed, 55 insertions(+), 14 deletions(-) diff --git a/server/vbv_lernwelt/iam/permissions.py b/server/vbv_lernwelt/iam/permissions.py index 3541cfe6..7558d0a5 100644 --- a/server/vbv_lernwelt/iam/permissions.py +++ b/server/vbv_lernwelt/iam/permissions.py @@ -1,5 +1,5 @@ from vbv_lernwelt.core.models import User -from vbv_lernwelt.course.models import CourseSession, CourseSessionUser +from vbv_lernwelt.course.models import Course, CourseSession, CourseSessionUser from vbv_lernwelt.course_session_group.models import CourseSessionGroup from vbv_lernwelt.learnpath.models import LearningSequence @@ -123,3 +123,11 @@ def can_view_course_session(user: User, course_session: CourseSession) -> bool: course_session=course_session, user=user, ).exists() + + +def has_role_in_course(user: User, course: Course) -> bool: + """ + Test for regio leiter, member, trainer... + """ + ... + return True diff --git a/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py b/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py index 8694ff3b..d651428e 100644 --- a/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py +++ b/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py @@ -7,14 +7,17 @@ from vbv_lernwelt.assignment.models import ( ) from vbv_lernwelt.course.creators.test_utils import ( create_assignment, + create_assignment_learning_content, create_circle, create_course, create_course_session, + create_course_session_assignment, create_user, ) from vbv_lernwelt.learning_mentor.views import ( CompletionStatus, get_assignment_completions, + get_praxis_assignments, ) @@ -52,7 +55,7 @@ class AttendanceServicesTestCase(TestCase): completion_status=AssignmentCompletionStatus.IN_PROGRESS.value, ) - def test_assignments(self): + def test_assignment_completions(self): # GIVEN participants = [self.user1, self.user2, self.user3, self.user4] @@ -66,11 +69,28 @@ class AttendanceServicesTestCase(TestCase): expected_statuses = { "Alpha": CompletionStatus.EVALUATED, # user1 "Beta": CompletionStatus.SUBMITTED, # user2 - "Gamma": CompletionStatus.PENDING, # user4 (no AssignmentCompletion) - "Kappa": CompletionStatus.PENDING, # user3 (IN_PROGRESS should be PENDING) + "Gamma": CompletionStatus.UNKNOWN, # user4 (no AssignmentCompletion) + "Kappa": CompletionStatus.UNKNOWN, # user3 (IN_PROGRESS should be PENDING) } self.assertEqual(len(results), len(participants)) for i, result in enumerate(results): self.assertEqual(result.last_name, expected_order[i]) self.assertEqual(result.status, expected_statuses[result.last_name]) + + def test_praxis_assignment_status(self): + # GIVEN + lca = create_assignment_learning_content(self.circle, self.assignment) + create_course_session_assignment( + course_session=self.course_session, learning_content_assignment=lca + ) + participants = [self.user1, self.user2, self.user3, self.user4] + + # WHEN + result = get_praxis_assignments(self.course_session, participants) + + # THEN + assignment = result[0] + self.assertEqual(assignment.pending_evaluations, 1) + self.assertEqual(assignment.title, "Dummy Assignment (CASEWORK)") + self.assertEqual(assignment.circle_id, self.circle.id) diff --git a/server/vbv_lernwelt/learning_mentor/views.py b/server/vbv_lernwelt/learning_mentor/views.py index c55eecc2..0702904d 100644 --- a/server/vbv_lernwelt/learning_mentor/views.py +++ b/server/vbv_lernwelt/learning_mentor/views.py @@ -19,7 +19,7 @@ from vbv_lernwelt.learning_mentor.models import LearningMentor class CompletionStatus(str, Enum): - PENDING = "PENDING" + UNKNOWN = "UNKNOWN" SUBMITTED = "SUBMITTED" EVALUATED = "EVALUATED" @@ -36,6 +36,7 @@ class PraxisAssignmentStatus: id: str title: str circle_id: str + pending_evaluations: int completions: List[PraxisAssignmentCompletion] @@ -60,7 +61,7 @@ def get_assignment_completions( ]: status = CompletionStatus.SUBMITTED else: - status = CompletionStatus.PENDING + status = CompletionStatus.UNKNOWN user_status_map[result["assignment_user"]] = ( status, @@ -70,14 +71,14 @@ def get_assignment_completions( status_priority = { CompletionStatus.SUBMITTED: 1, CompletionStatus.EVALUATED: 2, - CompletionStatus.PENDING: 3, + CompletionStatus.UNKNOWN: 3, } sorted_participants = sorted( participants, key=lambda u: ( status_priority.get( - user_status_map.get(u.id, (CompletionStatus.PENDING, ""))[0] + user_status_map.get(u.id, (CompletionStatus.UNKNOWN, ""))[0] ), user_status_map.get(u.id, ("", u.last_name))[1], ), @@ -86,7 +87,7 @@ def get_assignment_completions( return [ PraxisAssignmentCompletion( status=user_status_map.get( - user.id, (CompletionStatus.PENDING, user.last_name) + user.id, (CompletionStatus.UNKNOWN, user.last_name) )[0], user_id=user.id, last_name=user.last_name, @@ -108,16 +109,28 @@ def get_praxis_assignments( ], ): learning_content = course_session_assignment.learning_content + + completions = get_assignment_completions( + course_session=course_session, + assignment=learning_content.content_assignment, + participants=participants, + ) + + submitted_count = len( + [ + completion + for completion in completions + if completion.status == CompletionStatus.SUBMITTED + ] + ) + records.append( PraxisAssignmentStatus( id=course_session_assignment.id, title=learning_content.content_assignment.title, circle_id=learning_content.get_circle().id, - completions=get_assignment_completions( - course_session=course_session, - assignment=learning_content.content_assignment, - participants=participants, - ), + pending_evaluations=submitted_count, + completions=completions, ) ) From 5d4e6983dee408bd8c9ce957f54ce6a02309abc6 Mon Sep 17 00:00:00 2001 From: Reto Aebersold Date: Thu, 7 Dec 2023 08:58:32 +0100 Subject: [PATCH 04/61] feat: praxis assignments --- server/vbv_lernwelt/course/models.py | 3 ++ server/vbv_lernwelt/learning_mentor/admin.py | 9 ++++-- server/vbv_lernwelt/learning_mentor/apps.py | 3 ++ .../migrations/0001_initial.py | 30 +++++++++++++------ server/vbv_lernwelt/learning_mentor/models.py | 15 ++++++---- .../vbv_lernwelt/learning_mentor/signals.py | 16 ++++++++++ .../learning_mentor/tests/test_assignments.py | 4 +-- server/vbv_lernwelt/learning_mentor/views.py | 3 +- 8 files changed, 62 insertions(+), 21 deletions(-) create mode 100644 server/vbv_lernwelt/learning_mentor/signals.py diff --git a/server/vbv_lernwelt/course/models.py b/server/vbv_lernwelt/course/models.py index e5f1d842..78c88b84 100644 --- a/server/vbv_lernwelt/course/models.py +++ b/server/vbv_lernwelt/course/models.py @@ -289,6 +289,9 @@ class CourseSessionUser(models.Model): ] ordering = ["user__last_name", "user__first_name", "user__email"] + def __str__(self): + return f"{self.user} ({self.course_session.title})" + class CircleDocument(models.Model): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) diff --git a/server/vbv_lernwelt/learning_mentor/admin.py b/server/vbv_lernwelt/learning_mentor/admin.py index 6fb7474e..cdbfadc0 100644 --- a/server/vbv_lernwelt/learning_mentor/admin.py +++ b/server/vbv_lernwelt/learning_mentor/admin.py @@ -5,6 +5,9 @@ from vbv_lernwelt.learning_mentor.models import LearningMentor @admin.register(LearningMentor) class LearningMentorAdmin(admin.ModelAdmin): - list_display = [ - "mentor_email", - ] + def student_count(self, obj): + return obj.students.count() + + student_count.short_description = "Students" + + list_display = ["mentor", "course", "student_count"] diff --git a/server/vbv_lernwelt/learning_mentor/apps.py b/server/vbv_lernwelt/learning_mentor/apps.py index eba5c3d6..1580676b 100644 --- a/server/vbv_lernwelt/learning_mentor/apps.py +++ b/server/vbv_lernwelt/learning_mentor/apps.py @@ -4,3 +4,6 @@ from django.apps import AppConfig class LearningMentorConfig(AppConfig): default_auto_field = "django.db.models.BigAutoField" name = "vbv_lernwelt.learning_mentor" + + def ready(self): + import vbv_lernwelt.learning_mentor.signals # noqa F401 diff --git a/server/vbv_lernwelt/learning_mentor/migrations/0001_initial.py b/server/vbv_lernwelt/learning_mentor/migrations/0001_initial.py index 7ca498da..3843648f 100644 --- a/server/vbv_lernwelt/learning_mentor/migrations/0001_initial.py +++ b/server/vbv_lernwelt/learning_mentor/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.20 on 2023-12-06 09:32 +# Generated by Django 3.2.20 on 2023-12-07 07:52 import django.db.models.deletion from django.conf import settings @@ -9,8 +9,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("course", "0005_course_enable_circle_documents"), migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("course", "0005_course_enable_circle_documents"), ] operations = [ @@ -18,26 +18,38 @@ class Migration(migrations.Migration): name="LearningMentor", fields=[ ( - "mentor_email", - models.EmailField( - max_length=254, primary_key=True, serialize=False + "id", + models.BigAutoField( + auto_created=True, + primary_key=True, + serialize=False, + verbose_name="ID", + ), + ), + ( + "course", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, to="course.course" ), ), ( "mentor", models.OneToOneField( - blank=True, - null=True, - on_delete=django.db.models.deletion.SET_NULL, + on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL, ), ), ( "students", models.ManyToManyField( - related_name="students", to="course.CourseSessionUser" + blank=True, + related_name="students", + to="course.CourseSessionUser", ), ), ], + options={ + "unique_together": {("mentor", "course")}, + }, ), ] diff --git a/server/vbv_lernwelt/learning_mentor/models.py b/server/vbv_lernwelt/learning_mentor/models.py index 933ff893..102fc6d4 100644 --- a/server/vbv_lernwelt/learning_mentor/models.py +++ b/server/vbv_lernwelt/learning_mentor/models.py @@ -5,15 +5,20 @@ from vbv_lernwelt.course.models import CourseSessionUser class LearningMentor(models.Model): - mentor_email = models.EmailField(primary_key=True) - mentor = models.OneToOneField( - User, on_delete=models.SET_NULL, null=True, blank=True + mentor = models.OneToOneField(User, on_delete=models.CASCADE) + course = models.ForeignKey("course.Course", on_delete=models.CASCADE) + + students = models.ManyToManyField( + CourseSessionUser, + related_name="students", + blank=True, ) - students = models.ManyToManyField(CourseSessionUser, related_name="students") + class Meta: + unique_together = [["mentor", "course"]] def __str__(self): - return self.mentor_email + return f"{self.mentor} ({self.course.title})" @property def course_sessions(self): diff --git a/server/vbv_lernwelt/learning_mentor/signals.py b/server/vbv_lernwelt/learning_mentor/signals.py new file mode 100644 index 00000000..0e011010 --- /dev/null +++ b/server/vbv_lernwelt/learning_mentor/signals.py @@ -0,0 +1,16 @@ +from django.core.exceptions import ValidationError +from django.db.models.signals import m2m_changed +from django.dispatch import receiver + +from .models import LearningMentor + + +@receiver(m2m_changed, sender=LearningMentor.students.through) +def validate_student(sender, instance, action, reverse, model, pk_set, **kwargs): + if action == "pre_add": + students = model.objects.filter(pk__in=pk_set) + for student in students: + if student.course_session.course != instance.course: + raise ValidationError( + "Student (CourseSessionUser) does not match the course for this mentor." + ) diff --git a/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py b/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py index d651428e..0e99d9bd 100644 --- a/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py +++ b/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py @@ -33,7 +33,7 @@ class AttendanceServicesTestCase(TestCase): self.circle, _ = create_circle(title="Circle", course_page=self.course_page) self.assignment = create_assignment( - course=self.course, assignment_type=AssignmentType.CASEWORK + course=self.course, assignment_type=AssignmentType.PRAXIS_ASSIGNMENT ) AssignmentCompletion.objects.create( @@ -92,5 +92,5 @@ class AttendanceServicesTestCase(TestCase): # THEN assignment = result[0] self.assertEqual(assignment.pending_evaluations, 1) - self.assertEqual(assignment.title, "Dummy Assignment (CASEWORK)") + self.assertEqual(assignment.title, "Dummy Assignment (PRAXIS_ASSIGNMENT)") self.assertEqual(assignment.circle_id, self.circle.id) diff --git a/server/vbv_lernwelt/learning_mentor/views.py b/server/vbv_lernwelt/learning_mentor/views.py index 0702904d..3482b0bf 100644 --- a/server/vbv_lernwelt/learning_mentor/views.py +++ b/server/vbv_lernwelt/learning_mentor/views.py @@ -104,8 +104,7 @@ def get_praxis_assignments( for course_session_assignment in CourseSessionAssignment.objects.filter( course_session=course_session, learning_content__content_assignment__assignment_type__in=[ - # TODO: Switch to PRAXIS_ASSIGNMENT - AssignmentType.CASEWORK.value, + AssignmentType.PRAXIS_ASSIGNMENT.value, ], ): learning_content = course_session_assignment.learning_content From 56ecb72751b03fe9181e10a848d35cb800d0c96b Mon Sep 17 00:00:00 2001 From: Reto Aebersold Date: Thu, 7 Dec 2023 10:06:17 +0100 Subject: [PATCH 05/61] feat: praxis assignments API --- server/vbv_lernwelt/learning_mentor/admin.py | 8 +- .../migrations/0001_initial.py | 8 +- server/vbv_lernwelt/learning_mentor/models.py | 6 +- .../vbv_lernwelt/learning_mentor/signals.py | 10 +- .../learning_mentor/tests/test_api.py | 91 ++++++++++++++++--- server/vbv_lernwelt/learning_mentor/views.py | 9 +- 6 files changed, 100 insertions(+), 32 deletions(-) diff --git a/server/vbv_lernwelt/learning_mentor/admin.py b/server/vbv_lernwelt/learning_mentor/admin.py index cdbfadc0..92ec7e7b 100644 --- a/server/vbv_lernwelt/learning_mentor/admin.py +++ b/server/vbv_lernwelt/learning_mentor/admin.py @@ -5,9 +5,9 @@ from vbv_lernwelt.learning_mentor.models import LearningMentor @admin.register(LearningMentor) class LearningMentorAdmin(admin.ModelAdmin): - def student_count(self, obj): - return obj.students.count() + def participant_count(self, obj): + return obj.participants.count() - student_count.short_description = "Students" + participant_count.short_description = "Participants" - list_display = ["mentor", "course", "student_count"] + list_display = ["mentor", "course", "participant_count"] diff --git a/server/vbv_lernwelt/learning_mentor/migrations/0001_initial.py b/server/vbv_lernwelt/learning_mentor/migrations/0001_initial.py index 3843648f..92274bcc 100644 --- a/server/vbv_lernwelt/learning_mentor/migrations/0001_initial.py +++ b/server/vbv_lernwelt/learning_mentor/migrations/0001_initial.py @@ -1,4 +1,4 @@ -# Generated by Django 3.2.20 on 2023-12-07 07:52 +# Generated by Django 3.2.20 on 2023-12-07 08:39 import django.db.models.deletion from django.conf import settings @@ -9,8 +9,8 @@ class Migration(migrations.Migration): initial = True dependencies = [ - migrations.swappable_dependency(settings.AUTH_USER_MODEL), ("course", "0005_course_enable_circle_documents"), + migrations.swappable_dependency(settings.AUTH_USER_MODEL), ] operations = [ @@ -40,10 +40,10 @@ class Migration(migrations.Migration): ), ), ( - "students", + "participants", models.ManyToManyField( blank=True, - related_name="students", + related_name="participants", to="course.CourseSessionUser", ), ), diff --git a/server/vbv_lernwelt/learning_mentor/models.py b/server/vbv_lernwelt/learning_mentor/models.py index 102fc6d4..1ecee56c 100644 --- a/server/vbv_lernwelt/learning_mentor/models.py +++ b/server/vbv_lernwelt/learning_mentor/models.py @@ -8,9 +8,9 @@ class LearningMentor(models.Model): mentor = models.OneToOneField(User, on_delete=models.CASCADE) course = models.ForeignKey("course.Course", on_delete=models.CASCADE) - students = models.ManyToManyField( + participants = models.ManyToManyField( CourseSessionUser, - related_name="students", + related_name="participants", blank=True, ) @@ -22,4 +22,4 @@ class LearningMentor(models.Model): @property def course_sessions(self): - return self.students.values_list("course_session", flat=True).distinct() + return self.participants.values_list("course_session", flat=True).distinct() diff --git a/server/vbv_lernwelt/learning_mentor/signals.py b/server/vbv_lernwelt/learning_mentor/signals.py index 0e011010..dd282f99 100644 --- a/server/vbv_lernwelt/learning_mentor/signals.py +++ b/server/vbv_lernwelt/learning_mentor/signals.py @@ -5,12 +5,12 @@ from django.dispatch import receiver from .models import LearningMentor -@receiver(m2m_changed, sender=LearningMentor.students.through) +@receiver(m2m_changed, sender=LearningMentor.participants.through) def validate_student(sender, instance, action, reverse, model, pk_set, **kwargs): if action == "pre_add": - students = model.objects.filter(pk__in=pk_set) - for student in students: - if student.course_session.course != instance.course: + participants = model.objects.filter(pk__in=pk_set) + for participant in participants: + if participant.course_session.course != instance.course: raise ValidationError( - "Student (CourseSessionUser) does not match the course for this mentor." + "Participant (CourseSessionUser) does not match the course for this mentor." ) diff --git a/server/vbv_lernwelt/learning_mentor/tests/test_api.py b/server/vbv_lernwelt/learning_mentor/tests/test_api.py index b4b7245b..fba08cee 100644 --- a/server/vbv_lernwelt/learning_mentor/tests/test_api.py +++ b/server/vbv_lernwelt/learning_mentor/tests/test_api.py @@ -1,26 +1,89 @@ from django.urls import reverse +from rest_framework import status from rest_framework.test import APITestCase -from vbv_lernwelt.course.creators.test_utils import create_course, create_course_session +from vbv_lernwelt.course.creators.test_utils import ( + add_course_session_user, + create_course, + create_course_session, + create_user, +) +from vbv_lernwelt.course.models import CourseSessionUser +from vbv_lernwelt.learning_mentor.models import LearningMentor class LearningMentorAPITest(APITestCase): - # def setUp(self) -> None: - # create_default_users() - # create_test_course(with_sessions=True) - # - # self.mentor = User.objects.get(username="expert-vv.expert1@eiger-versicherungen.ch") - - def test_api(self) -> None: - # GIVEN + def setUp(self) -> None: course, course_page = create_course("Test Course") - course_session = create_course_session(course=course, title="Test VV") + self.course_session = create_course_session(course=course, title="Test VV") + self.mentor = create_user("mentor") + self.participant_1 = add_course_session_user( + self.course_session, + create_user("participant_1"), + role=CourseSessionUser.Role.MEMBER, + ) + self.participant_2 = add_course_session_user( + self.course_session, + create_user("participant_2"), + role=CourseSessionUser.Role.MEMBER, + ) + self.participant_3 = add_course_session_user( + self.course_session, + create_user("participant_3"), + role=CourseSessionUser.Role.MEMBER, + ) - # self.client.force_login(self.mentor) - url = reverse("mentor_summary", kwargs={"course_session_id": course_session.id}) + self.url = reverse( + "mentor_summary", kwargs={"course_session_id": self.course_session.id} + ) + + def test_api_no_mentor(self) -> None: + # GIVEN + self.client.force_login(self.mentor) # WHEN - response = self.client.get(url) + response = self.client.get(self.url) # THEN - # self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.status_code, status.HTTP_404_NOT_FOUND) + + def test_api_no_participants(self) -> None: + # GIVEN + self.client.force_login(self.mentor) + LearningMentor.objects.create( + mentor=self.mentor, course=self.course_session.course + ) + + # WHEN + response = self.client.get(self.url) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data["participants"], []) + self.assertEqual(response.data["praxis_assignments"], []) + + def test_api_participants(self) -> None: + # GIVEN + participants = [self.participant_1, self.participant_2, self.participant_3] + self.client.force_login(self.mentor) + mentor = LearningMentor.objects.create( + mentor=self.mentor, + course=self.course_session.course, + ) + mentor.participants.set(participants) + + # WHEN + response = self.client.get(self.url) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["participants"]), len(participants)) + + participant_1 = [ + p + for p in response.data["participants"] + if p["id"] == str(self.participant_1.user.id) + ][0] + self.assertEqual(participant_1["email"], "participant_1@example.com") + self.assertEqual(participant_1["first_name"], "Test") + self.assertEqual(participant_1["last_name"], "Participant_1") diff --git a/server/vbv_lernwelt/learning_mentor/views.py b/server/vbv_lernwelt/learning_mentor/views.py index 3482b0bf..98c7ee08 100644 --- a/server/vbv_lernwelt/learning_mentor/views.py +++ b/server/vbv_lernwelt/learning_mentor/views.py @@ -3,6 +3,7 @@ from enum import Enum from typing import List from rest_framework.decorators import api_view +from rest_framework.generics import get_object_or_404 from rest_framework.response import Response from vbv_lernwelt.assignment.models import ( @@ -144,11 +145,15 @@ def mentor_summary(request, course_session_id: int): if request.method == "GET": course_session = CourseSession.objects.get(id=course_session_id) - participants = LearningMentor.objects.filter(mentor=request.user) + mentor = get_object_or_404( + LearningMentor, mentor=request.user, course=course_session.course + ) + + participants = mentor.participants.filter(course_session=course_session) return Response( { - "participants": [UserSerializer(s).data for s in participants], + "participants": [UserSerializer(p.user).data for p in participants], "praxis_assignments": get_praxis_assignments( course_session, participants ), From a48ac35e629a412bf8e2da84d28b837d2f11a389 Mon Sep 17 00:00:00 2001 From: Reto Aebersold Date: Thu, 7 Dec 2023 11:19:56 +0100 Subject: [PATCH 06/61] feat: praxis assignments API --- .../learning_mentor/content/__init__.py | 0 .../content/praxis_assignment.py | 115 ++++++++++++++ .../vbv_lernwelt/learning_mentor/entities.py | 25 +++ .../learning_mentor/serializers.py | 19 +++ .../learning_mentor/tests/test_api.py | 70 ++++++++- .../learning_mentor/tests/test_assignments.py | 4 +- server/vbv_lernwelt/learning_mentor/views.py | 145 ++---------------- 7 files changed, 240 insertions(+), 138 deletions(-) create mode 100644 server/vbv_lernwelt/learning_mentor/content/__init__.py create mode 100644 server/vbv_lernwelt/learning_mentor/content/praxis_assignment.py create mode 100644 server/vbv_lernwelt/learning_mentor/entities.py create mode 100644 server/vbv_lernwelt/learning_mentor/serializers.py diff --git a/server/vbv_lernwelt/learning_mentor/content/__init__.py b/server/vbv_lernwelt/learning_mentor/content/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/learning_mentor/content/praxis_assignment.py b/server/vbv_lernwelt/learning_mentor/content/praxis_assignment.py new file mode 100644 index 00000000..8c2e7024 --- /dev/null +++ b/server/vbv_lernwelt/learning_mentor/content/praxis_assignment.py @@ -0,0 +1,115 @@ +from typing import List + +from vbv_lernwelt.assignment.models import ( + Assignment, + AssignmentCompletion, + AssignmentCompletionStatus, + AssignmentType, +) +from vbv_lernwelt.core.models import User +from vbv_lernwelt.course.models import CourseSession +from vbv_lernwelt.course_session.models import CourseSessionAssignment +from vbv_lernwelt.learning_mentor.entities import ( + CompletionStatus, + PraxisAssignmentCompletion, + PraxisAssignmentStatus, +) + + +def get_assignment_completions( + course_session: CourseSession, assignment: Assignment, participants: List[User] +) -> List[PraxisAssignmentCompletion]: + evaluation_results = AssignmentCompletion.objects.filter( + assignment_user__in=participants, + course_session=course_session, + assignment=assignment, + ).values("completion_status", "assignment_user__last_name", "assignment_user") + + user_status_map = {} + for result in evaluation_results: + completion_status = result["completion_status"] + + if completion_status == AssignmentCompletionStatus.EVALUATION_SUBMITTED.value: + status = CompletionStatus.EVALUATED + elif completion_status in [ + AssignmentCompletionStatus.SUBMITTED.value, + AssignmentCompletionStatus.EVALUATION_IN_PROGRESS.value, + ]: + status = CompletionStatus.SUBMITTED + else: + status = CompletionStatus.UNKNOWN + + user_status_map[result["assignment_user"]] = ( + status, + result["assignment_user__last_name"], + ) + + status_priority = { + CompletionStatus.SUBMITTED: 1, + CompletionStatus.EVALUATED: 2, + CompletionStatus.UNKNOWN: 3, + } + + sorted_participants = sorted( + participants, + key=lambda u: ( + status_priority.get( + user_status_map.get(u.id, (CompletionStatus.UNKNOWN, ""))[0] + ), + user_status_map.get(u.id, ("", u.last_name))[1], + ), + ) + + return [ + PraxisAssignmentCompletion( + status=user_status_map.get( + user.id, (CompletionStatus.UNKNOWN, user.last_name) + )[0], + user_id=user.id, + last_name=user.last_name, + ) + for user in sorted_participants + ] + + +def get_praxis_assignments( + course_session: CourseSession, participants: List[User] +) -> List[PraxisAssignmentStatus]: + records = [] + + if not participants: + return records + + for course_session_assignment in CourseSessionAssignment.objects.filter( + course_session=course_session, + learning_content__content_assignment__assignment_type__in=[ + AssignmentType.PRAXIS_ASSIGNMENT.value, + ], + ): + learning_content = course_session_assignment.learning_content + + completions = get_assignment_completions( + course_session=course_session, + assignment=learning_content.content_assignment, + participants=participants, + ) + + submitted_count = len( + [ + completion + for completion in completions + if completion.status == CompletionStatus.SUBMITTED + ] + ) + + records.append( + PraxisAssignmentStatus( + id=course_session_assignment.id, + title=learning_content.content_assignment.title, + circle_id=learning_content.get_circle().id, + pending_evaluations=submitted_count, + completions=completions, + ) + ) + + return records diff --git a/server/vbv_lernwelt/learning_mentor/entities.py b/server/vbv_lernwelt/learning_mentor/entities.py new file mode 100644 index 00000000..6dabcc26 --- /dev/null +++ b/server/vbv_lernwelt/learning_mentor/entities.py @@ -0,0 +1,25 @@ +from dataclasses import dataclass +from enum import Enum +from typing import List + + +class CompletionStatus(str, Enum): + UNKNOWN = "UNKNOWN" + SUBMITTED = "SUBMITTED" + EVALUATED = "EVALUATED" + + +@dataclass +class PraxisAssignmentCompletion: + status: CompletionStatus + user_id: str + last_name: str + + +@dataclass +class PraxisAssignmentStatus: + id: str + title: str + circle_id: str + pending_evaluations: int + completions: List[PraxisAssignmentCompletion] diff --git a/server/vbv_lernwelt/learning_mentor/serializers.py b/server/vbv_lernwelt/learning_mentor/serializers.py new file mode 100644 index 00000000..6904d5b2 --- /dev/null +++ b/server/vbv_lernwelt/learning_mentor/serializers.py @@ -0,0 +1,19 @@ +from rest_framework import serializers + + +class PraxisAssignmentCompletionSerializer(serializers.Serializer): + status = serializers.SerializerMethodField() + user_id = serializers.CharField() + last_name = serializers.CharField() + + @staticmethod + def get_status(obj): + return obj.status.value + + +class PraxisAssignmentStatusSerializer(serializers.Serializer): + id = serializers.CharField() + title = serializers.CharField() + circle_id = serializers.CharField() + pending_evaluations = serializers.IntegerField() + completions = PraxisAssignmentCompletionSerializer(many=True) diff --git a/server/vbv_lernwelt/learning_mentor/tests/test_api.py b/server/vbv_lernwelt/learning_mentor/tests/test_api.py index fba08cee..7f536912 100644 --- a/server/vbv_lernwelt/learning_mentor/tests/test_api.py +++ b/server/vbv_lernwelt/learning_mentor/tests/test_api.py @@ -2,10 +2,19 @@ from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase +from vbv_lernwelt.assignment.models import ( + AssignmentCompletion, + AssignmentCompletionStatus, + AssignmentType, +) from vbv_lernwelt.course.creators.test_utils import ( add_course_session_user, + create_assignment, + create_assignment_learning_content, + create_circle, create_course, create_course_session, + create_course_session_assignment, create_user, ) from vbv_lernwelt.course.models import CourseSessionUser @@ -14,8 +23,20 @@ from vbv_lernwelt.learning_mentor.models import LearningMentor class LearningMentorAPITest(APITestCase): def setUp(self) -> None: - course, course_page = create_course("Test Course") - self.course_session = create_course_session(course=course, title="Test VV") + self.course, self.course_page = create_course("Test Course") + self.course_session = create_course_session(course=self.course, title="Test VV") + + circle, _ = create_circle(title="Circle", course_page=self.course_page) + + self.assignment = create_assignment( + course=self.course, assignment_type=AssignmentType.PRAXIS_ASSIGNMENT + ) + + lca = create_assignment_learning_content(circle, self.assignment) + create_course_session_assignment( + course_session=self.course_session, learning_content_assignment=lca + ) + self.mentor = create_user("mentor") self.participant_1 = add_course_session_user( self.course_session, @@ -87,3 +108,48 @@ class LearningMentorAPITest(APITestCase): self.assertEqual(participant_1["email"], "participant_1@example.com") self.assertEqual(participant_1["first_name"], "Test") self.assertEqual(participant_1["last_name"], "Participant_1") + + def test_api_praxis_assignments(self) -> None: + # GIVEN + participants = [self.participant_1, self.participant_2, self.participant_3] + self.client.force_login(self.mentor) + + mentor = LearningMentor.objects.create( + mentor=self.mentor, + course=self.course_session.course, + ) + mentor.participants.set(participants) + + AssignmentCompletion.objects.create( + assignment_user=self.participant_1.user, + course_session=self.course_session, + assignment=self.assignment, + completion_status=AssignmentCompletionStatus.EVALUATION_SUBMITTED.value, + ) + + AssignmentCompletion.objects.create( + assignment_user=self.participant_3.user, + course_session=self.course_session, + assignment=self.assignment, + completion_status=AssignmentCompletionStatus.SUBMITTED.value, + ) + + # WHEN + response = self.client.get(self.url) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data["praxis_assignments"]), 1) + + assignment = response.data["praxis_assignments"][0] + self.assertEqual(assignment["pending_evaluations"], 1) + + self.assertEqual( + assignment["completions"][0]["last_name"], self.participant_3.user.last_name + ) + self.assertEqual( + assignment["completions"][1]["last_name"], self.participant_1.user.last_name + ) + self.assertEqual( + assignment["completions"][2]["last_name"], self.participant_2.user.last_name + ) diff --git a/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py b/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py index 0e99d9bd..4e2f7fa4 100644 --- a/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py +++ b/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py @@ -14,11 +14,11 @@ from vbv_lernwelt.course.creators.test_utils import ( create_course_session_assignment, create_user, ) -from vbv_lernwelt.learning_mentor.views import ( - CompletionStatus, +from vbv_lernwelt.learning_mentor.content.praxis_assignment import ( get_assignment_completions, get_praxis_assignments, ) +from vbv_lernwelt.learning_mentor.entities import CompletionStatus class AttendanceServicesTestCase(TestCase): diff --git a/server/vbv_lernwelt/learning_mentor/views.py b/server/vbv_lernwelt/learning_mentor/views.py index 98c7ee08..98d71414 100644 --- a/server/vbv_lernwelt/learning_mentor/views.py +++ b/server/vbv_lernwelt/learning_mentor/views.py @@ -1,140 +1,14 @@ -from dataclasses import dataclass -from enum import Enum -from typing import List - from rest_framework.decorators import api_view from rest_framework.generics import get_object_or_404 from rest_framework.response import Response -from vbv_lernwelt.assignment.models import ( - Assignment, - AssignmentCompletion, - AssignmentCompletionStatus, - AssignmentType, -) -from vbv_lernwelt.core.models import User from vbv_lernwelt.core.serializers import UserSerializer from vbv_lernwelt.course.models import CourseSession -from vbv_lernwelt.course_session.models import CourseSessionAssignment +from vbv_lernwelt.learning_mentor.content.praxis_assignment import ( + get_praxis_assignments, +) from vbv_lernwelt.learning_mentor.models import LearningMentor - - -class CompletionStatus(str, Enum): - UNKNOWN = "UNKNOWN" - SUBMITTED = "SUBMITTED" - EVALUATED = "EVALUATED" - - -@dataclass -class PraxisAssignmentCompletion: - status: CompletionStatus - user_id: str - last_name: str - - -@dataclass -class PraxisAssignmentStatus: - id: str - title: str - circle_id: str - pending_evaluations: int - completions: List[PraxisAssignmentCompletion] - - -def get_assignment_completions( - course_session: CourseSession, assignment: Assignment, participants: List[User] -) -> List[PraxisAssignmentCompletion]: - evaluation_results = AssignmentCompletion.objects.filter( - assignment_user__in=participants, - course_session=course_session, - assignment=assignment, - ).values("completion_status", "assignment_user__last_name", "assignment_user") - - user_status_map = {} - for result in evaluation_results: - completion_status = result["completion_status"] - - if completion_status == AssignmentCompletionStatus.EVALUATION_SUBMITTED.value: - status = CompletionStatus.EVALUATED - elif completion_status in [ - AssignmentCompletionStatus.SUBMITTED.value, - AssignmentCompletionStatus.EVALUATION_IN_PROGRESS.value, - ]: - status = CompletionStatus.SUBMITTED - else: - status = CompletionStatus.UNKNOWN - - user_status_map[result["assignment_user"]] = ( - status, - result["assignment_user__last_name"], - ) - - status_priority = { - CompletionStatus.SUBMITTED: 1, - CompletionStatus.EVALUATED: 2, - CompletionStatus.UNKNOWN: 3, - } - - sorted_participants = sorted( - participants, - key=lambda u: ( - status_priority.get( - user_status_map.get(u.id, (CompletionStatus.UNKNOWN, ""))[0] - ), - user_status_map.get(u.id, ("", u.last_name))[1], - ), - ) - - return [ - PraxisAssignmentCompletion( - status=user_status_map.get( - user.id, (CompletionStatus.UNKNOWN, user.last_name) - )[0], - user_id=user.id, - last_name=user.last_name, - ) - for user in sorted_participants - ] - - -def get_praxis_assignments( - course_session: CourseSession, participants: List[User] -) -> List[PraxisAssignmentStatus]: - records = [] - - for course_session_assignment in CourseSessionAssignment.objects.filter( - course_session=course_session, - learning_content__content_assignment__assignment_type__in=[ - AssignmentType.PRAXIS_ASSIGNMENT.value, - ], - ): - learning_content = course_session_assignment.learning_content - - completions = get_assignment_completions( - course_session=course_session, - assignment=learning_content.content_assignment, - participants=participants, - ) - - submitted_count = len( - [ - completion - for completion in completions - if completion.status == CompletionStatus.SUBMITTED - ] - ) - - records.append( - PraxisAssignmentStatus( - id=course_session_assignment.id, - title=learning_content.content_assignment.title, - circle_id=learning_content.get_circle().id, - pending_evaluations=submitted_count, - completions=completions, - ) - ) - - return records +from vbv_lernwelt.learning_mentor.serializers import PraxisAssignmentStatusSerializer @api_view(["GET"]) @@ -150,12 +24,15 @@ def mentor_summary(request, course_session_id: int): ) participants = mentor.participants.filter(course_session=course_session) + users = [p.user for p in participants] + + praxis_assignments = get_praxis_assignments(course_session, users) return Response( { - "participants": [UserSerializer(p.user).data for p in participants], - "praxis_assignments": get_praxis_assignments( - course_session, participants - ), + "participants": [UserSerializer(user).data for user in users], + "praxis_assignments": PraxisAssignmentStatusSerializer( + praxis_assignments, many=True + ).data, } ) From 50738b2bf3d59da86bdebf57e9796f0a1e07e96c Mon Sep 17 00:00:00 2001 From: Reto Aebersold Date: Thu, 7 Dec 2023 11:49:22 +0100 Subject: [PATCH 07/61] feat: add circles --- .../learning_mentor/content/praxis_assignment.py | 15 ++++++++++----- .../learning_mentor/tests/test_api.py | 9 +++++++-- .../learning_mentor/tests/test_assignments.py | 7 +++++-- server/vbv_lernwelt/learning_mentor/views.py | 6 +++++- 4 files changed, 27 insertions(+), 10 deletions(-) diff --git a/server/vbv_lernwelt/learning_mentor/content/praxis_assignment.py b/server/vbv_lernwelt/learning_mentor/content/praxis_assignment.py index 8c2e7024..0b5ffcfa 100644 --- a/server/vbv_lernwelt/learning_mentor/content/praxis_assignment.py +++ b/server/vbv_lernwelt/learning_mentor/content/praxis_assignment.py @@ -1,4 +1,4 @@ -from typing import List +from typing import List, Set, Tuple from vbv_lernwelt.assignment.models import ( Assignment, @@ -74,11 +74,12 @@ def get_assignment_completions( def get_praxis_assignments( course_session: CourseSession, participants: List[User] -) -> List[PraxisAssignmentStatus]: +) -> Tuple[List[PraxisAssignmentStatus], Set[int]]: records = [] + circle_ids = set() if not participants: - return records + return records, circle_ids for course_session_assignment in CourseSessionAssignment.objects.filter( course_session=course_session, @@ -102,14 +103,18 @@ def get_praxis_assignments( ] ) + circle_id = learning_content.get_circle().id + records.append( PraxisAssignmentStatus( id=course_session_assignment.id, title=learning_content.content_assignment.title, - circle_id=learning_content.get_circle().id, + circle_id=circle_id, pending_evaluations=submitted_count, completions=completions, ) ) - return records + circle_ids.add(circle_id) + + return records, circle_ids diff --git a/server/vbv_lernwelt/learning_mentor/tests/test_api.py b/server/vbv_lernwelt/learning_mentor/tests/test_api.py index 7f536912..b41f5d55 100644 --- a/server/vbv_lernwelt/learning_mentor/tests/test_api.py +++ b/server/vbv_lernwelt/learning_mentor/tests/test_api.py @@ -26,13 +26,13 @@ class LearningMentorAPITest(APITestCase): self.course, self.course_page = create_course("Test Course") self.course_session = create_course_session(course=self.course, title="Test VV") - circle, _ = create_circle(title="Circle", course_page=self.course_page) + self.circle, _ = create_circle(title="Circle", course_page=self.course_page) self.assignment = create_assignment( course=self.course, assignment_type=AssignmentType.PRAXIS_ASSIGNMENT ) - lca = create_assignment_learning_content(circle, self.assignment) + lca = create_assignment_learning_content(self.circle, self.assignment) create_course_session_assignment( course_session=self.course_session, learning_content_assignment=lca ) @@ -141,6 +141,11 @@ class LearningMentorAPITest(APITestCase): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(len(response.data["praxis_assignments"]), 1) + self.assertEqual( + response.data["circles"], + [{"id": self.circle.id, "title": self.circle.title}], + ) + assignment = response.data["praxis_assignments"][0] self.assertEqual(assignment["pending_evaluations"], 1) diff --git a/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py b/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py index 4e2f7fa4..069fa180 100644 --- a/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py +++ b/server/vbv_lernwelt/learning_mentor/tests/test_assignments.py @@ -87,10 +87,13 @@ class AttendanceServicesTestCase(TestCase): participants = [self.user1, self.user2, self.user3, self.user4] # WHEN - result = get_praxis_assignments(self.course_session, participants) + assignments, circle_ids = get_praxis_assignments( + self.course_session, participants + ) # THEN - assignment = result[0] + assignment = assignments[0] self.assertEqual(assignment.pending_evaluations, 1) self.assertEqual(assignment.title, "Dummy Assignment (PRAXIS_ASSIGNMENT)") self.assertEqual(assignment.circle_id, self.circle.id) + self.assertEqual(list(circle_ids)[0], self.circle.id) diff --git a/server/vbv_lernwelt/learning_mentor/views.py b/server/vbv_lernwelt/learning_mentor/views.py index 98d71414..e791071d 100644 --- a/server/vbv_lernwelt/learning_mentor/views.py +++ b/server/vbv_lernwelt/learning_mentor/views.py @@ -9,6 +9,7 @@ from vbv_lernwelt.learning_mentor.content.praxis_assignment import ( ) from vbv_lernwelt.learning_mentor.models import LearningMentor from vbv_lernwelt.learning_mentor.serializers import PraxisAssignmentStatusSerializer +from vbv_lernwelt.learnpath.models import Circle @api_view(["GET"]) @@ -26,11 +27,14 @@ def mentor_summary(request, course_session_id: int): participants = mentor.participants.filter(course_session=course_session) users = [p.user for p in participants] - praxis_assignments = get_praxis_assignments(course_session, users) + praxis_assignments, circle_ids = get_praxis_assignments(course_session, users) + + circles = Circle.objects.filter(id__in=circle_ids).values("id", "title") return Response( { "participants": [UserSerializer(user).data for user in users], + "circles": list(circles), "praxis_assignments": PraxisAssignmentStatusSerializer( praxis_assignments, many=True ).data, From 3205eac33fd557ccfa60e0aef1a90062bfb90de7 Mon Sep 17 00:00:00 2001 From: Reto Aebersold Date: Fri, 8 Dec 2023 08:31:42 +0100 Subject: [PATCH 08/61] feat: mentor invitation --- server/vbv_lernwelt/learning_mentor/admin.py | 8 ++- .../migrations/0002_mentorinvitation.py | 31 ++++++++ .../migrations/0003_auto_20231207_1448.py | 21 ++++++ server/vbv_lernwelt/learning_mentor/models.py | 20 ++++++ server/vbv_lernwelt/learning_mentor/views.py | 72 ++++++++++++------- 5 files changed, 126 insertions(+), 26 deletions(-) create mode 100644 server/vbv_lernwelt/learning_mentor/migrations/0002_mentorinvitation.py create mode 100644 server/vbv_lernwelt/learning_mentor/migrations/0003_auto_20231207_1448.py diff --git a/server/vbv_lernwelt/learning_mentor/admin.py b/server/vbv_lernwelt/learning_mentor/admin.py index 92ec7e7b..112b4838 100644 --- a/server/vbv_lernwelt/learning_mentor/admin.py +++ b/server/vbv_lernwelt/learning_mentor/admin.py @@ -1,6 +1,6 @@ from django.contrib import admin -from vbv_lernwelt.learning_mentor.models import LearningMentor +from vbv_lernwelt.learning_mentor.models import LearningMentor, MentorInvitation @admin.register(LearningMentor) @@ -11,3 +11,9 @@ class LearningMentorAdmin(admin.ModelAdmin): participant_count.short_description = "Participants" list_display = ["mentor", "course", "participant_count"] + + +@admin.register(MentorInvitation) +class MentorInvitationAdmin(admin.ModelAdmin): + list_display = ["id", "email", "participant", "created"] + readonly_fields = ["id", "created"] diff --git a/server/vbv_lernwelt/learning_mentor/migrations/0002_mentorinvitation.py b/server/vbv_lernwelt/learning_mentor/migrations/0002_mentorinvitation.py new file mode 100644 index 00000000..5d0dc191 --- /dev/null +++ b/server/vbv_lernwelt/learning_mentor/migrations/0002_mentorinvitation.py @@ -0,0 +1,31 @@ +# Generated by Django 3.2.20 on 2023-12-07 13:46 + +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + ('course', '0005_course_enable_circle_documents'), + ('learning_mentor', '0001_initial'), + ] + + operations = [ + migrations.CreateModel( + name='MentorInvitation', + fields=[ + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), + ('email', models.EmailField(max_length=254)), + ('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.coursesessionuser')), + ], + options={ + 'verbose_name': 'Mentor Invitation', + 'verbose_name_plural': 'Mentor Invitations', + }, + ), + ] diff --git a/server/vbv_lernwelt/learning_mentor/migrations/0003_auto_20231207_1448.py b/server/vbv_lernwelt/learning_mentor/migrations/0003_auto_20231207_1448.py new file mode 100644 index 00000000..b14bbb59 --- /dev/null +++ b/server/vbv_lernwelt/learning_mentor/migrations/0003_auto_20231207_1448.py @@ -0,0 +1,21 @@ +# Generated by Django 3.2.20 on 2023-12-07 13:48 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('learning_mentor', '0002_mentorinvitation'), + ] + + operations = [ + migrations.AlterModelOptions( + name='learningmentor', + options={'verbose_name': 'Lernbegleiter', 'verbose_name_plural': 'Lernbegleiter'}, + ), + migrations.AlterModelOptions( + name='mentorinvitation', + options={'verbose_name': 'Lernbegleiter Einladung', 'verbose_name_plural': 'Lernbegleiter Einladungen'}, + ), + ] diff --git a/server/vbv_lernwelt/learning_mentor/models.py b/server/vbv_lernwelt/learning_mentor/models.py index 1ecee56c..07c31dc3 100644 --- a/server/vbv_lernwelt/learning_mentor/models.py +++ b/server/vbv_lernwelt/learning_mentor/models.py @@ -1,4 +1,7 @@ +import uuid + from django.db import models +from django_extensions.db.models import TimeStampedModel from vbv_lernwelt.core.models import User from vbv_lernwelt.course.models import CourseSessionUser @@ -16,6 +19,8 @@ class LearningMentor(models.Model): class Meta: unique_together = [["mentor", "course"]] + verbose_name = "Lernbegleiter" + verbose_name_plural = "Lernbegleiter" def __str__(self): return f"{self.mentor} ({self.course.title})" @@ -23,3 +28,18 @@ class LearningMentor(models.Model): @property def course_sessions(self): return self.participants.values_list("course_session", flat=True).distinct() + + +class MentorInvitation(TimeStampedModel): + id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) + email = models.EmailField() + participant = models.ForeignKey( + CourseSessionUser, on_delete=models.CASCADE + ) + + def __str__(self): + return f"{self.email} ({self.participant})" + + class Meta: + verbose_name = "Lernbegleiter Einladung" + verbose_name_plural = "Lernbegleiter Einladungen" diff --git a/server/vbv_lernwelt/learning_mentor/views.py b/server/vbv_lernwelt/learning_mentor/views.py index e791071d..8ea1393d 100644 --- a/server/vbv_lernwelt/learning_mentor/views.py +++ b/server/vbv_lernwelt/learning_mentor/views.py @@ -1,5 +1,7 @@ -from rest_framework.decorators import api_view +from django.shortcuts import redirect +from rest_framework.decorators import api_view, permission_classes from rest_framework.generics import get_object_or_404 +from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from vbv_lernwelt.core.serializers import UserSerializer @@ -13,30 +15,50 @@ from vbv_lernwelt.learnpath.models import Circle @api_view(["GET"]) +@permission_classes([IsAuthenticated]) def mentor_summary(request, course_session_id: int): + course_session = CourseSession.objects.get(id=course_session_id) + + mentor = get_object_or_404( + LearningMentor, mentor=request.user, course=course_session.course + ) + + participants = mentor.participants.filter(course_session=course_session) + users = [p.user for p in participants] + + praxis_assignments, circle_ids = get_praxis_assignments(course_session, users) + + circles = Circle.objects.filter(id__in=circle_ids).values("id", "title") + + return Response( + { + "participants": [UserSerializer(user).data for user in users], + "circles": list(circles), + "praxis_assignments": PraxisAssignmentStatusSerializer( + praxis_assignments, many=True + ).data, + } + ) + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def list_invitations(request, course_session_id: int): + pass + + +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def create_invitation(request, course_session_id: int): + # Validate request + # Create invitation + # Send email + pass + + +@api_view(["GET"]) +def accept_invitation(request, course_session_id: int): if not request.user.is_authenticated: - return Response(status=403) + return redirect(f"/onboarding/vv/account/create?next={request.path}") - if request.method == "GET": - course_session = CourseSession.objects.get(id=course_session_id) - - mentor = get_object_or_404( - LearningMentor, mentor=request.user, course=course_session.course - ) - - participants = mentor.participants.filter(course_session=course_session) - users = [p.user for p in participants] - - praxis_assignments, circle_ids = get_praxis_assignments(course_session, users) - - circles = Circle.objects.filter(id__in=circle_ids).values("id", "title") - - return Response( - { - "participants": [UserSerializer(user).data for user in users], - "circles": list(circles), - "praxis_assignments": PraxisAssignmentStatusSerializer( - praxis_assignments, many=True - ).data, - } - ) + # Validate invitation From 16a6334802fd765e123e0d565e8bbe230eb009a0 Mon Sep 17 00:00:00 2001 From: Reto Aebersold Date: Mon, 11 Dec 2023 10:01:24 +0100 Subject: [PATCH 09/61] feat: mentor invitation URLs --- server/config/urls.py | 5 +- server/vbv_lernwelt/iam/permissions.py | 11 ++++ .../migrations/0002_mentorinvitation.py | 50 ++++++++++++++----- .../migrations/0003_auto_20231207_1448.py | 17 ++++--- ..._alter_mentorinvitation_unique_together.py | 17 +++++++ server/vbv_lernwelt/learning_mentor/models.py | 5 +- .../learning_mentor/tests/test_invitation.py | 50 +++++++++++++++++++ server/vbv_lernwelt/learning_mentor/urls.py | 8 +++ server/vbv_lernwelt/learning_mentor/views.py | 34 ++++++++++--- 9 files changed, 164 insertions(+), 33 deletions(-) create mode 100644 server/vbv_lernwelt/learning_mentor/migrations/0004_alter_mentorinvitation_unique_together.py create mode 100644 server/vbv_lernwelt/learning_mentor/tests/test_invitation.py create mode 100644 server/vbv_lernwelt/learning_mentor/urls.py diff --git a/server/config/urls.py b/server/config/urls.py index 87376ca5..9e3094c9 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -53,7 +53,6 @@ from vbv_lernwelt.importer.views import ( coursesessions_trainers_import, t2l_sync, ) -from vbv_lernwelt.learning_mentor.views import mentor_summary from vbv_lernwelt.notify.views import email_notification_settings from wagtail import urls as wagtail_urls from wagtail.admin import urls as wagtailadmin_urls @@ -126,9 +125,7 @@ urlpatterns = [ request_course_completion_for_user, name="request_course_completion_for_user"), - path(r"api/mentor//", - mentor_summary, - name="mentor_summary"), + path("api/mentor//", include("vbv_lernwelt.learning_mentor.urls")), # assignment path( diff --git a/server/vbv_lernwelt/iam/permissions.py b/server/vbv_lernwelt/iam/permissions.py index 7558d0a5..c82510fe 100644 --- a/server/vbv_lernwelt/iam/permissions.py +++ b/server/vbv_lernwelt/iam/permissions.py @@ -48,6 +48,17 @@ def is_course_session_expert(user, course_session_id: int): return is_supervisor or is_expert +def is_course_session_member(user, course_session_id: int | None = None): + if course_session_id is None: + return False + + return CourseSessionUser.objects.filter( + course_session_id=course_session_id, + user=user, + role=CourseSessionUser.Role.MEMBER, + ).exists() + + def can_evaluate_assignments(user, course_session_id: int): if user.is_superuser: return True diff --git a/server/vbv_lernwelt/learning_mentor/migrations/0002_mentorinvitation.py b/server/vbv_lernwelt/learning_mentor/migrations/0002_mentorinvitation.py index 5d0dc191..30c20c01 100644 --- a/server/vbv_lernwelt/learning_mentor/migrations/0002_mentorinvitation.py +++ b/server/vbv_lernwelt/learning_mentor/migrations/0002_mentorinvitation.py @@ -1,31 +1,55 @@ # Generated by Django 3.2.20 on 2023-12-07 13:46 -from django.db import migrations, models +import uuid + import django.db.models.deletion import django_extensions.db.fields -import uuid +from django.db import migrations, models class Migration(migrations.Migration): - dependencies = [ - ('course', '0005_course_enable_circle_documents'), - ('learning_mentor', '0001_initial'), + ("course", "0005_course_enable_circle_documents"), + ("learning_mentor", "0001_initial"), ] operations = [ migrations.CreateModel( - name='MentorInvitation', + name="MentorInvitation", fields=[ - ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), - ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), - ('id', models.UUIDField(default=uuid.uuid4, editable=False, primary_key=True, serialize=False)), - ('email', models.EmailField(max_length=254)), - ('participant', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='course.coursesessionuser')), + ( + "created", + django_extensions.db.fields.CreationDateTimeField( + auto_now_add=True, verbose_name="created" + ), + ), + ( + "modified", + django_extensions.db.fields.ModificationDateTimeField( + auto_now=True, verbose_name="modified" + ), + ), + ( + "id", + models.UUIDField( + default=uuid.uuid4, + editable=False, + primary_key=True, + serialize=False, + ), + ), + ("email", models.EmailField(max_length=254)), + ( + "participant", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="course.coursesessionuser", + ), + ), ], options={ - 'verbose_name': 'Mentor Invitation', - 'verbose_name_plural': 'Mentor Invitations', + "verbose_name": "Mentor Invitation", + "verbose_name_plural": "Mentor Invitations", }, ), ] diff --git a/server/vbv_lernwelt/learning_mentor/migrations/0003_auto_20231207_1448.py b/server/vbv_lernwelt/learning_mentor/migrations/0003_auto_20231207_1448.py index b14bbb59..b9de393d 100644 --- a/server/vbv_lernwelt/learning_mentor/migrations/0003_auto_20231207_1448.py +++ b/server/vbv_lernwelt/learning_mentor/migrations/0003_auto_20231207_1448.py @@ -4,18 +4,23 @@ from django.db import migrations class Migration(migrations.Migration): - dependencies = [ - ('learning_mentor', '0002_mentorinvitation'), + ("learning_mentor", "0002_mentorinvitation"), ] operations = [ migrations.AlterModelOptions( - name='learningmentor', - options={'verbose_name': 'Lernbegleiter', 'verbose_name_plural': 'Lernbegleiter'}, + name="learningmentor", + options={ + "verbose_name": "Lernbegleiter", + "verbose_name_plural": "Lernbegleiter", + }, ), migrations.AlterModelOptions( - name='mentorinvitation', - options={'verbose_name': 'Lernbegleiter Einladung', 'verbose_name_plural': 'Lernbegleiter Einladungen'}, + name="mentorinvitation", + options={ + "verbose_name": "Lernbegleiter Einladung", + "verbose_name_plural": "Lernbegleiter Einladungen", + }, ), ] diff --git a/server/vbv_lernwelt/learning_mentor/migrations/0004_alter_mentorinvitation_unique_together.py b/server/vbv_lernwelt/learning_mentor/migrations/0004_alter_mentorinvitation_unique_together.py new file mode 100644 index 00000000..8f8e2b30 --- /dev/null +++ b/server/vbv_lernwelt/learning_mentor/migrations/0004_alter_mentorinvitation_unique_together.py @@ -0,0 +1,17 @@ +# Generated by Django 3.2.20 on 2023-12-11 09:00 + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("course", "0005_course_enable_circle_documents"), + ("learning_mentor", "0003_auto_20231207_1448"), + ] + + operations = [ + migrations.AlterUniqueTogether( + name="mentorinvitation", + unique_together={("email", "participant")}, + ), + ] diff --git a/server/vbv_lernwelt/learning_mentor/models.py b/server/vbv_lernwelt/learning_mentor/models.py index 07c31dc3..c6e8aa2e 100644 --- a/server/vbv_lernwelt/learning_mentor/models.py +++ b/server/vbv_lernwelt/learning_mentor/models.py @@ -33,9 +33,7 @@ class LearningMentor(models.Model): class MentorInvitation(TimeStampedModel): id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) email = models.EmailField() - participant = models.ForeignKey( - CourseSessionUser, on_delete=models.CASCADE - ) + participant = models.ForeignKey(CourseSessionUser, on_delete=models.CASCADE) def __str__(self): return f"{self.email} ({self.participant})" @@ -43,3 +41,4 @@ class MentorInvitation(TimeStampedModel): class Meta: verbose_name = "Lernbegleiter Einladung" verbose_name_plural = "Lernbegleiter Einladungen" + unique_together = [["email", "participant"]] diff --git a/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py b/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py new file mode 100644 index 00000000..5eacddbe --- /dev/null +++ b/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py @@ -0,0 +1,50 @@ +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from vbv_lernwelt.course.creators.test_utils import ( + add_course_session_user, + create_course, + create_course_session, + create_user, +) +from vbv_lernwelt.course.models import CourseSessionUser + + +class LearningMentorInvitationTest(APITestCase): + def setUp(self) -> None: + self.course, self.course_page = create_course("Test Course") + self.course_session = create_course_session(course=self.course, title="Test VV") + + self.participant = create_user("participant") + + def test_create_invitation_not_member(self) -> None: + # GIVEN + self.client.force_login(self.participant) + invite_url = reverse( + "create_invitation", kwargs={"course_session_id": self.course_session.id} + ) + + # WHEN + response = self.client.post(invite_url) + + # THEN + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + + def test_create_invitation(self) -> None: + # GIVEN + self.client.force_login(self.participant) + add_course_session_user( + self.course_session, + self.participant, + role=CourseSessionUser.Role.MEMBER, + ) + invite_url = reverse( + "create_invitation", kwargs={"course_session_id": self.course_session.id} + ) + + # WHEN + response = self.client.post(invite_url, data={"email": "test@example.com"}) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) diff --git a/server/vbv_lernwelt/learning_mentor/urls.py b/server/vbv_lernwelt/learning_mentor/urls.py new file mode 100644 index 00000000..88e9cba3 --- /dev/null +++ b/server/vbv_lernwelt/learning_mentor/urls.py @@ -0,0 +1,8 @@ +from django.urls import path + +from . import views + +urlpatterns = [ + path("summary", views.mentor_summary, name="mentor_summary"), + path("invite", views.create_invitation, name="create_invitation"), +] diff --git a/server/vbv_lernwelt/learning_mentor/views.py b/server/vbv_lernwelt/learning_mentor/views.py index 8ea1393d..e3de9cad 100644 --- a/server/vbv_lernwelt/learning_mentor/views.py +++ b/server/vbv_lernwelt/learning_mentor/views.py @@ -1,15 +1,17 @@ from django.shortcuts import redirect +from rest_framework import permissions from rest_framework.decorators import api_view, permission_classes from rest_framework.generics import get_object_or_404 from rest_framework.permissions import IsAuthenticated from rest_framework.response import Response from vbv_lernwelt.core.serializers import UserSerializer -from vbv_lernwelt.course.models import CourseSession +from vbv_lernwelt.course.models import CourseSession, CourseSessionUser +from vbv_lernwelt.iam.permissions import is_course_session_member from vbv_lernwelt.learning_mentor.content.praxis_assignment import ( get_praxis_assignments, ) -from vbv_lernwelt.learning_mentor.models import LearningMentor +from vbv_lernwelt.learning_mentor.models import LearningMentor, MentorInvitation from vbv_lernwelt.learning_mentor.serializers import PraxisAssignmentStatusSerializer from vbv_lernwelt.learnpath.models import Circle @@ -41,6 +43,13 @@ def mentor_summary(request, course_session_id: int): ) +class CourseSessionMember(permissions.BasePermission): + def has_permission(self, request, view): + return is_course_session_member( + request.user, view.kwargs.get("course_session_id") + ) + + @api_view(["GET"]) @permission_classes([IsAuthenticated]) def list_invitations(request, course_session_id: int): @@ -48,12 +57,23 @@ def list_invitations(request, course_session_id: int): @api_view(["POST"]) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAuthenticated, CourseSessionMember]) def create_invitation(request, course_session_id: int): - # Validate request - # Create invitation - # Send email - pass + course_session = get_object_or_404(CourseSession, id=course_session_id) + course_session_user = get_object_or_404( + CourseSessionUser, user=request.user, course_session=course_session + ) + + email = request.data.get("email") + if not email: + return Response({"error": "email is required"}, status=400) + + invitation = MentorInvitation.objects.get_or_create( + email=email, + participant=course_session_user, + ) + + return Response({}) @api_view(["GET"]) From cfb38cc9fef8bf765e37b60a5e05eb9afb75ae98 Mon Sep 17 00:00:00 2001 From: Reto Aebersold Date: Mon, 11 Dec 2023 12:03:17 +0100 Subject: [PATCH 10/61] feat: mentor invite --- .../learning_mentor/tests/test_invitation.py | 46 +++++++++++++++++-- .../tests/{test_api.py => test_mentor.py} | 0 server/vbv_lernwelt/learning_mentor/urls.py | 1 + server/vbv_lernwelt/learning_mentor/views.py | 32 +++++++++---- .../notify/email/email_services.py | 3 ++ 5 files changed, 70 insertions(+), 12 deletions(-) rename server/vbv_lernwelt/learning_mentor/tests/{test_api.py => test_mentor.py} (100%) diff --git a/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py b/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py index 5eacddbe..aefff4c8 100644 --- a/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py +++ b/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py @@ -1,3 +1,5 @@ +from unittest.mock import patch + from django.urls import reverse from rest_framework import status from rest_framework.test import APITestCase @@ -9,6 +11,8 @@ from vbv_lernwelt.course.creators.test_utils import ( create_user, ) from vbv_lernwelt.course.models import CourseSessionUser +from vbv_lernwelt.learning_mentor.models import MentorInvitation +from vbv_lernwelt.notify.email.email_services import EmailTemplate class LearningMentorInvitationTest(APITestCase): @@ -31,10 +35,11 @@ class LearningMentorInvitationTest(APITestCase): # THEN self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) - def test_create_invitation(self) -> None: + @patch("vbv_lernwelt.learning_mentor.views.send_email") + def test_create_invitation(self, mock_send_mail) -> None: # GIVEN self.client.force_login(self.participant) - add_course_session_user( + participant_cs_user = add_course_session_user( self.course_session, self.participant, role=CourseSessionUser.Role.MEMBER, @@ -42,9 +47,44 @@ class LearningMentorInvitationTest(APITestCase): invite_url = reverse( "create_invitation", kwargs={"course_session_id": self.course_session.id} ) + email = "test@example.com" # WHEN - response = self.client.post(invite_url, data={"email": "test@example.com"}) + response = self.client.post(invite_url, data={"email": email}) # THEN + invitation_id = response.data["invitation"] self.assertEqual(response.status_code, status.HTTP_200_OK) + + self.assertTrue( + MentorInvitation.objects.filter( + id=invitation_id, + ).exists() + ) + + mock_send_mail.assert_called_once_with( + recipient_email=email, + template=EmailTemplate.LEARNING_MENTOR_INVITATION, + template_data={ + "inviter_name": f"{self.participant.first_name} {self.participant.last_name}", + "inviter_email": self.participant.email, + "target_url": f"https://my.vbv-afa.ch/lernbegleitung/{self.course_session.id}/invitation/{invitation_id}", + }, + template_language=self.participant.language, + fail_silently=True, + ) + + def test_accept_invitation(self) -> None: + # GIVEN + invitee = create_user("invitee") + self.client.force_login(invitee) + + accept_url = reverse( + "create_invitation", kwargs={"course_session_id": self.course_session.id} + ) + + # WHEN + response = self.client.get(accept_url) + + # THEN + self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) diff --git a/server/vbv_lernwelt/learning_mentor/tests/test_api.py b/server/vbv_lernwelt/learning_mentor/tests/test_mentor.py similarity index 100% rename from server/vbv_lernwelt/learning_mentor/tests/test_api.py rename to server/vbv_lernwelt/learning_mentor/tests/test_mentor.py diff --git a/server/vbv_lernwelt/learning_mentor/urls.py b/server/vbv_lernwelt/learning_mentor/urls.py index 88e9cba3..0f184849 100644 --- a/server/vbv_lernwelt/learning_mentor/urls.py +++ b/server/vbv_lernwelt/learning_mentor/urls.py @@ -5,4 +5,5 @@ from . import views urlpatterns = [ path("summary", views.mentor_summary, name="mentor_summary"), path("invite", views.create_invitation, name="create_invitation"), + path("accept", views.accept_invitation, name="accept_invitation"), ] diff --git a/server/vbv_lernwelt/learning_mentor/views.py b/server/vbv_lernwelt/learning_mentor/views.py index e3de9cad..8729bac2 100644 --- a/server/vbv_lernwelt/learning_mentor/views.py +++ b/server/vbv_lernwelt/learning_mentor/views.py @@ -1,4 +1,3 @@ -from django.shortcuts import redirect from rest_framework import permissions from rest_framework.decorators import api_view, permission_classes from rest_framework.generics import get_object_or_404 @@ -14,6 +13,7 @@ from vbv_lernwelt.learning_mentor.content.praxis_assignment import ( from vbv_lernwelt.learning_mentor.models import LearningMentor, MentorInvitation from vbv_lernwelt.learning_mentor.serializers import PraxisAssignmentStatusSerializer from vbv_lernwelt.learnpath.models import Circle +from vbv_lernwelt.notify.email.email_services import EmailTemplate, send_email @api_view(["GET"]) @@ -59,26 +59,40 @@ def list_invitations(request, course_session_id: int): @api_view(["POST"]) @permission_classes([IsAuthenticated, CourseSessionMember]) def create_invitation(request, course_session_id: int): + user = request.user + course_session = get_object_or_404(CourseSession, id=course_session_id) course_session_user = get_object_or_404( - CourseSessionUser, user=request.user, course_session=course_session + CourseSessionUser, user=user, course_session=course_session ) email = request.data.get("email") if not email: return Response({"error": "email is required"}, status=400) - invitation = MentorInvitation.objects.get_or_create( + invitation, _ = MentorInvitation.objects.get_or_create( email=email, participant=course_session_user, ) - return Response({}) + target_url = f"/lernbegleitung/{course_session_id}/invitation/{invitation.id}" + + send_email( + recipient_email=email, + template=EmailTemplate.LEARNING_MENTOR_INVITATION, + template_data={ + "inviter_name": f"{user.first_name} {user.last_name}", + "inviter_email": user.email, + "target_url": f"https://my.vbv-afa.ch{target_url}", + }, + template_language=request.user.language, + fail_silently=True, + ) + + return Response({"invitation": invitation.id}) -@api_view(["GET"]) +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) def accept_invitation(request, course_session_id: int): - if not request.user.is_authenticated: - return redirect(f"/onboarding/vv/account/create?next={request.path}") - - # Validate invitation + return Response({}) diff --git a/server/vbv_lernwelt/notify/email/email_services.py b/server/vbv_lernwelt/notify/email/email_services.py index 66cb7b97..ed36f748 100644 --- a/server/vbv_lernwelt/notify/email/email_services.py +++ b/server/vbv_lernwelt/notify/email/email_services.py @@ -62,6 +62,9 @@ class EmailTemplate(Enum): # VBV - Neues Feedback fĂĽr Circle NEW_FEEDBACK = {"de": "d-40fb94d5149949e7b8e7ddfcf0fcfdde"} + # VBV - Lernbegleitung Einladung + LEARNING_MENTOR_INVITATION = {"de": "", "fr": "", "it": ""} + def send_email( recipient_email: str, From 86d3644ca60dfe02e26fbe172f4ea805541182a0 Mon Sep 17 00:00:00 2001 From: Reto Aebersold Date: Mon, 11 Dec 2023 13:39:17 +0100 Subject: [PATCH 11/61] feat: mentor invite --- .../learning_mentor/serializers.py | 16 +++++++++ .../learning_mentor/tests/test_invitation.py | 30 ++++++++++++++-- server/vbv_lernwelt/learning_mentor/urls.py | 5 +-- server/vbv_lernwelt/learning_mentor/views.py | 35 ++++++++++++------- .../notify/email/email_services.py | 6 +++- 5 files changed, 74 insertions(+), 18 deletions(-) diff --git a/server/vbv_lernwelt/learning_mentor/serializers.py b/server/vbv_lernwelt/learning_mentor/serializers.py index 6904d5b2..146f1911 100644 --- a/server/vbv_lernwelt/learning_mentor/serializers.py +++ b/server/vbv_lernwelt/learning_mentor/serializers.py @@ -1,5 +1,7 @@ from rest_framework import serializers +from vbv_lernwelt.learning_mentor.models import MentorInvitation + class PraxisAssignmentCompletionSerializer(serializers.Serializer): status = serializers.SerializerMethodField() @@ -17,3 +19,17 @@ class PraxisAssignmentStatusSerializer(serializers.Serializer): circle_id = serializers.CharField() pending_evaluations = serializers.IntegerField() completions = PraxisAssignmentCompletionSerializer(many=True) + + +class InvitationSerializer(serializers.ModelSerializer): + class Meta: + model = MentorInvitation + fields = ["id", "email"] + read_only_fields = ["id"] + + def create(self, validated_data): + participant = self.context["course_session_user"] + invitation, _ = MentorInvitation.objects.get_or_create( + email=validated_data["email"], defaults={"participant": participant} + ) + return invitation diff --git a/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py b/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py index aefff4c8..a5022eff 100644 --- a/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py +++ b/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py @@ -39,7 +39,7 @@ class LearningMentorInvitationTest(APITestCase): def test_create_invitation(self, mock_send_mail) -> None: # GIVEN self.client.force_login(self.participant) - participant_cs_user = add_course_session_user( + add_course_session_user( self.course_session, self.participant, role=CourseSessionUser.Role.MEMBER, @@ -53,7 +53,7 @@ class LearningMentorInvitationTest(APITestCase): response = self.client.post(invite_url, data={"email": email}) # THEN - invitation_id = response.data["invitation"] + invitation_id = response.data["id"] self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertTrue( @@ -74,6 +74,32 @@ class LearningMentorInvitationTest(APITestCase): fail_silently=True, ) + def test_list_invitations(self) -> None: + # GIVEN + self.client.force_login(self.participant) + participant_cs_user = add_course_session_user( + self.course_session, + self.participant, + role=CourseSessionUser.Role.MEMBER, + ) + email = "test@exmaple.com" + + MentorInvitation.objects.create(participant=participant_cs_user, email=email) + + list_url = reverse( + "list_invitations", kwargs={"course_session_id": self.course_session.id} + ) + + # WHEN + response = self.client.get(list_url) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual( + response.data, + [{"id": str(MentorInvitation.objects.get(email=email).id), "email": email}], + ) + def test_accept_invitation(self) -> None: # GIVEN invitee = create_user("invitee") diff --git a/server/vbv_lernwelt/learning_mentor/urls.py b/server/vbv_lernwelt/learning_mentor/urls.py index 0f184849..3859bafc 100644 --- a/server/vbv_lernwelt/learning_mentor/urls.py +++ b/server/vbv_lernwelt/learning_mentor/urls.py @@ -4,6 +4,7 @@ from . import views urlpatterns = [ path("summary", views.mentor_summary, name="mentor_summary"), - path("invite", views.create_invitation, name="create_invitation"), - path("accept", views.accept_invitation, name="accept_invitation"), + path("invitations", views.list_invitations, name="list_invitations"), + path("invitations/create", views.create_invitation, name="create_invitation"), + path("invitations/accept", views.accept_invitation, name="accept_invitation"), ] diff --git a/server/vbv_lernwelt/learning_mentor/views.py b/server/vbv_lernwelt/learning_mentor/views.py index 8729bac2..f381d8d5 100644 --- a/server/vbv_lernwelt/learning_mentor/views.py +++ b/server/vbv_lernwelt/learning_mentor/views.py @@ -1,4 +1,4 @@ -from rest_framework import permissions +from rest_framework import permissions, status from rest_framework.decorators import api_view, permission_classes from rest_framework.generics import get_object_or_404 from rest_framework.permissions import IsAuthenticated @@ -11,7 +11,10 @@ from vbv_lernwelt.learning_mentor.content.praxis_assignment import ( get_praxis_assignments, ) from vbv_lernwelt.learning_mentor.models import LearningMentor, MentorInvitation -from vbv_lernwelt.learning_mentor.serializers import PraxisAssignmentStatusSerializer +from vbv_lernwelt.learning_mentor.serializers import ( + InvitationSerializer, + PraxisAssignmentStatusSerializer, +) from vbv_lernwelt.learnpath.models import Circle from vbv_lernwelt.notify.email.email_services import EmailTemplate, send_email @@ -51,9 +54,15 @@ class CourseSessionMember(permissions.BasePermission): @api_view(["GET"]) -@permission_classes([IsAuthenticated]) +@permission_classes([IsAuthenticated, CourseSessionMember]) def list_invitations(request, course_session_id: int): - pass + course_session = get_object_or_404(CourseSession, id=course_session_id) + course_session_user = get_object_or_404( + CourseSessionUser, user=request.user, course_session=course_session + ) + snippets = MentorInvitation.objects.filter(participant=course_session_user) + serializer = InvitationSerializer(snippets, many=True) + return Response(serializer.data) @api_view(["POST"]) @@ -66,19 +75,19 @@ def create_invitation(request, course_session_id: int): CourseSessionUser, user=user, course_session=course_session ) - email = request.data.get("email") - if not email: - return Response({"error": "email is required"}, status=400) - - invitation, _ = MentorInvitation.objects.get_or_create( - email=email, - participant=course_session_user, + serializer = InvitationSerializer( + data=request.data, context={"course_session_user": course_session_user} ) + if not serializer.is_valid(): + return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST) + + invitation = serializer.save() + target_url = f"/lernbegleitung/{course_session_id}/invitation/{invitation.id}" send_email( - recipient_email=email, + recipient_email=invitation.email, template=EmailTemplate.LEARNING_MENTOR_INVITATION, template_data={ "inviter_name": f"{user.first_name} {user.last_name}", @@ -89,7 +98,7 @@ def create_invitation(request, course_session_id: int): fail_silently=True, ) - return Response({"invitation": invitation.id}) + return Response(serializer.data) @api_view(["POST"]) diff --git a/server/vbv_lernwelt/notify/email/email_services.py b/server/vbv_lernwelt/notify/email/email_services.py index ed36f748..6bc0c42a 100644 --- a/server/vbv_lernwelt/notify/email/email_services.py +++ b/server/vbv_lernwelt/notify/email/email_services.py @@ -63,7 +63,11 @@ class EmailTemplate(Enum): NEW_FEEDBACK = {"de": "d-40fb94d5149949e7b8e7ddfcf0fcfdde"} # VBV - Lernbegleitung Einladung - LEARNING_MENTOR_INVITATION = {"de": "", "fr": "", "it": ""} + LEARNING_MENTOR_INVITATION = { + "de": "d-8c862afde62748b6b8410887eeee89d8", + "fr": "d-7451e3c858954c15a9f410fa9d92dc06", + "it": "d-30c6aa9accda4973a940dd25703cb4a9", + } def send_email( From b75737468c2670df8bfb2d08c08c7e6d2baef8f0 Mon Sep 17 00:00:00 2001 From: Reto Aebersold Date: Mon, 11 Dec 2023 13:55:40 +0100 Subject: [PATCH 12/61] feat: mentor invite --- .../learning_mentor/tests/test_invitation.py | 29 +++++++++++++++++++ server/vbv_lernwelt/learning_mentor/urls.py | 5 ++++ server/vbv_lernwelt/learning_mentor/views.py | 20 +++++++++++-- 3 files changed, 52 insertions(+), 2 deletions(-) diff --git a/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py b/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py index a5022eff..7486c892 100644 --- a/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py +++ b/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py @@ -100,6 +100,35 @@ class LearningMentorInvitationTest(APITestCase): [{"id": str(MentorInvitation.objects.get(email=email).id), "email": email}], ) + def test_delete_invitation(self) -> None: + # GIVEN + self.client.force_login(self.participant) + participant_cs_user = add_course_session_user( + self.course_session, + self.participant, + role=CourseSessionUser.Role.MEMBER, + ) + email = "test@exmaple.com" + + invitation = MentorInvitation.objects.create( + participant=participant_cs_user, email=email + ) + + delete_url = reverse( + "delete_invitation", + kwargs={ + "course_session_id": self.course_session.id, + "invitation_id": invitation.id, + }, + ) + + # WHEN + response = self.client.delete(delete_url) + + # THEN + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse(MentorInvitation.objects.filter(id=invitation.id).exists()) + def test_accept_invitation(self) -> None: # GIVEN invitee = create_user("invitee") diff --git a/server/vbv_lernwelt/learning_mentor/urls.py b/server/vbv_lernwelt/learning_mentor/urls.py index 3859bafc..57f33a8e 100644 --- a/server/vbv_lernwelt/learning_mentor/urls.py +++ b/server/vbv_lernwelt/learning_mentor/urls.py @@ -6,5 +6,10 @@ urlpatterns = [ path("summary", views.mentor_summary, name="mentor_summary"), path("invitations", views.list_invitations, name="list_invitations"), path("invitations/create", views.create_invitation, name="create_invitation"), + path( + "invitations//delete", + views.delete_invitation, + name="delete_invitation", + ), path("invitations/accept", views.accept_invitation, name="accept_invitation"), ] diff --git a/server/vbv_lernwelt/learning_mentor/views.py b/server/vbv_lernwelt/learning_mentor/views.py index f381d8d5..73f2078d 100644 --- a/server/vbv_lernwelt/learning_mentor/views.py +++ b/server/vbv_lernwelt/learning_mentor/views.py @@ -1,3 +1,5 @@ +from uuid import UUID + from rest_framework import permissions, status from rest_framework.decorators import api_view, permission_classes from rest_framework.generics import get_object_or_404 @@ -60,11 +62,25 @@ def list_invitations(request, course_session_id: int): course_session_user = get_object_or_404( CourseSessionUser, user=request.user, course_session=course_session ) - snippets = MentorInvitation.objects.filter(participant=course_session_user) - serializer = InvitationSerializer(snippets, many=True) + invitations = MentorInvitation.objects.filter(participant=course_session_user) + serializer = InvitationSerializer(invitations, many=True) return Response(serializer.data) +@api_view(["DELETE"]) +@permission_classes([IsAuthenticated, CourseSessionMember]) +def delete_invitation(request, course_session_id: int, invitation_id: UUID): + course_session = get_object_or_404(CourseSession, id=course_session_id) + course_session_user = get_object_or_404( + CourseSessionUser, user=request.user, course_session=course_session + ) + get_object_or_404( + MentorInvitation, id=invitation_id, participant=course_session_user + ).delete() + + return Response(status=status.HTTP_204_NO_CONTENT) + + @api_view(["POST"]) @permission_classes([IsAuthenticated, CourseSessionMember]) def create_invitation(request, course_session_id: int): From 27ab8caf24564902f52760f2391d8fe5f264fc74 Mon Sep 17 00:00:00 2001 From: Reto Aebersold Date: Mon, 11 Dec 2023 14:48:42 +0100 Subject: [PATCH 13/61] feat: mentor mgmt --- .../learning_mentor/tests/test_invitation.py | 4 +- .../learning_mentor/tests/test_mentor.py | 68 +++++++++++++++++++ server/vbv_lernwelt/learning_mentor/urls.py | 6 ++ server/vbv_lernwelt/learning_mentor/views.py | 33 +++++++++ 4 files changed, 109 insertions(+), 2 deletions(-) diff --git a/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py b/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py index 7486c892..34558583 100644 --- a/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py +++ b/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py @@ -82,7 +82,7 @@ class LearningMentorInvitationTest(APITestCase): self.participant, role=CourseSessionUser.Role.MEMBER, ) - email = "test@exmaple.com" + email = "test@example.com" MentorInvitation.objects.create(participant=participant_cs_user, email=email) @@ -108,7 +108,7 @@ class LearningMentorInvitationTest(APITestCase): self.participant, role=CourseSessionUser.Role.MEMBER, ) - email = "test@exmaple.com" + email = "test@example.com" invitation = MentorInvitation.objects.create( participant=participant_cs_user, email=email diff --git a/server/vbv_lernwelt/learning_mentor/tests/test_mentor.py b/server/vbv_lernwelt/learning_mentor/tests/test_mentor.py index b41f5d55..4c7d105c 100644 --- a/server/vbv_lernwelt/learning_mentor/tests/test_mentor.py +++ b/server/vbv_lernwelt/learning_mentor/tests/test_mentor.py @@ -158,3 +158,71 @@ class LearningMentorAPITest(APITestCase): self.assertEqual( assignment["completions"][2]["last_name"], self.participant_2.user.last_name ) + + def test_list_user_mentors(self) -> None: + # GIVEN + participant = create_user("participant") + self.client.force_login(participant) + + participant_cs_user = add_course_session_user( + self.course_session, + participant, + role=CourseSessionUser.Role.MEMBER, + ) + + learning_mentor = LearningMentor.objects.create( + mentor=self.mentor, + course=self.course_session.course, + ) + + learning_mentor.participants.add(participant_cs_user) + + list_url = reverse( + "list_user_mentors", kwargs={"course_session_id": self.course_session.id} + ) + + # WHEN + response = self.client.get(list_url) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + + mentor_user = response.data[0] + + self.assertEqual(mentor_user["email"], self.mentor.email) + self.assertEqual(mentor_user["id"], str(self.mentor.id)) + + def test_remove_user_mentor(self) -> None: + # GIVEN + participant = create_user("participant") + self.client.force_login(participant) + + participant_cs_user = add_course_session_user( + self.course_session, + participant, + role=CourseSessionUser.Role.MEMBER, + ) + + learning_mentor = LearningMentor.objects.create( + mentor=self.mentor, + course=self.course_session.course, + ) + + learning_mentor.participants.add(participant_cs_user) + + remove_self_url = reverse( + "remove_self_from_mentor", + kwargs={ + "course_session_id": self.course_session.id, + "mentor_id": learning_mentor.id, + }, + ) + + # WHEN + response = self.client.delete(remove_self_url) + + # THEN + self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) + self.assertFalse( + LearningMentor.objects.filter(participants=participant_cs_user).exists() + ) diff --git a/server/vbv_lernwelt/learning_mentor/urls.py b/server/vbv_lernwelt/learning_mentor/urls.py index 57f33a8e..c4c53f2f 100644 --- a/server/vbv_lernwelt/learning_mentor/urls.py +++ b/server/vbv_lernwelt/learning_mentor/urls.py @@ -4,6 +4,12 @@ from . import views urlpatterns = [ path("summary", views.mentor_summary, name="mentor_summary"), + path("mentors", views.list_user_mentors, name="list_user_mentors"), + path( + "mentors//leave", + views.remove_self_from_mentor, + name="remove_self_from_mentor", + ), path("invitations", views.list_invitations, name="list_invitations"), path("invitations/create", views.create_invitation, name="create_invitation"), path( diff --git a/server/vbv_lernwelt/learning_mentor/views.py b/server/vbv_lernwelt/learning_mentor/views.py index 73f2078d..0904b692 100644 --- a/server/vbv_lernwelt/learning_mentor/views.py +++ b/server/vbv_lernwelt/learning_mentor/views.py @@ -117,6 +117,39 @@ def create_invitation(request, course_session_id: int): return Response(serializer.data) +@api_view(["GET"]) +@permission_classes([IsAuthenticated, CourseSessionMember]) +def list_user_mentors(request, course_session_id: int): + course_session = get_object_or_404(CourseSession, id=course_session_id) + + course_session_user = get_object_or_404( + CourseSessionUser, user=request.user, course_session=course_session + ) + + mentors = LearningMentor.objects.filter( + course=course_session.course, participants=course_session_user + ) + + mentor_users = [mentor.mentor for mentor in mentors] + + return Response(UserSerializer(mentor_users, many=True).data) + + +@api_view(["DELETE"]) +@permission_classes([IsAuthenticated, CourseSessionMember]) +def remove_self_from_mentor(request, course_session_id: int, mentor_id: int): + course_session = get_object_or_404(CourseSession, id=course_session_id) + course_session_user = get_object_or_404( + CourseSessionUser, user=request.user, course_session=course_session + ) + + mentor = get_object_or_404(LearningMentor, id=mentor_id) + + mentor.participants.remove(course_session_user) + + return Response(status=status.HTTP_204_NO_CONTENT) + + @api_view(["POST"]) @permission_classes([IsAuthenticated]) def accept_invitation(request, course_session_id: int): From 3e2cededc713817ee50baa04ac5fc1ce9ae2dcb0 Mon Sep 17 00:00:00 2001 From: Reto Aebersold Date: Mon, 11 Dec 2023 16:13:25 +0100 Subject: [PATCH 14/61] chore: fix button links --- .../cockpitPage/SubmissionsOverview.vue | 24 +++++++++---------- 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/client/src/pages/cockpit/cockpitPage/SubmissionsOverview.vue b/client/src/pages/cockpit/cockpitPage/SubmissionsOverview.vue index 2362bb2c..a13ff616 100644 --- a/client/src/pages/cockpit/cockpitPage/SubmissionsOverview.vue +++ b/client/src/pages/cockpit/cockpitPage/SubmissionsOverview.vue @@ -187,18 +187,18 @@ const getIconName = (lc: LearningContent) => { class="grow pr-8" >
- + + {{ submittable.showDetailsText }} +
From e2c32b7fb662778f9e7a868803c43949415ff727 Mon Sep 17 00:00:00 2001 From: Reto Aebersold Date: Mon, 11 Dec 2023 16:51:33 +0100 Subject: [PATCH 15/61] feat: cockpit type --- server/config/urls.py | 4 ++ server/vbv_lernwelt/api/__init__.py | 0 server/vbv_lernwelt/api/tests/__init__.py | 0 server/vbv_lernwelt/api/tests/test_cockpit.py | 59 +++++++++++++++++++ server/vbv_lernwelt/api/user.py | 27 +++++++++ server/vbv_lernwelt/course/models.py | 1 - server/vbv_lernwelt/learning_mentor/views.py | 16 +++++ 7 files changed, 106 insertions(+), 1 deletion(-) create mode 100644 server/vbv_lernwelt/api/__init__.py create mode 100644 server/vbv_lernwelt/api/tests/__init__.py create mode 100644 server/vbv_lernwelt/api/tests/test_cockpit.py create mode 100644 server/vbv_lernwelt/api/user.py diff --git a/server/config/urls.py b/server/config/urls.py index 9e3094c9..59202975 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -11,6 +11,7 @@ from django.views.decorators.csrf import csrf_exempt from django_ratelimit.exceptions import Ratelimited from graphene_django.views import GraphQLView +from vbv_lernwelt.api.user import get_cockpit_type from vbv_lernwelt.assignment.views import request_assignment_completion_status from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt from vbv_lernwelt.core.schema import schema @@ -124,6 +125,9 @@ urlpatterns = [ path(r"api/course/completion///", request_course_completion_for_user, name="request_course_completion_for_user"), + path(r"api/course//cockpit/", + get_cockpit_type, + name="get_cockpit_type"), path("api/mentor//", include("vbv_lernwelt.learning_mentor.urls")), diff --git a/server/vbv_lernwelt/api/__init__.py b/server/vbv_lernwelt/api/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/api/tests/__init__.py b/server/vbv_lernwelt/api/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/api/tests/test_cockpit.py b/server/vbv_lernwelt/api/tests/test_cockpit.py new file mode 100644 index 00000000..4802101e --- /dev/null +++ b/server/vbv_lernwelt/api/tests/test_cockpit.py @@ -0,0 +1,59 @@ +from django.urls import reverse +from rest_framework import status +from rest_framework.test import APITestCase + +from vbv_lernwelt.course.creators.test_utils import ( + create_course, + create_course_session, + create_user, +) +from vbv_lernwelt.course.models import CourseSessionUser +from vbv_lernwelt.learning_mentor.models import LearningMentor + + +class MeUserViewTest(APITestCase): + def setUp(self) -> None: + self.course, _ = create_course("Test Course") + self.user = create_user("tester") + self.url = reverse("get_cockpit_type", kwargs={"course_id": self.course.id}) + + def test_no_cockpit(self) -> None: + # GIVEN + self.client.force_login(self.user) + + # WHEN + response = self.client.get(self.url) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEquals(response.data["type"], None) + + def test_mentor_cockpit(self) -> None: + # GIVEN + self.client.force_login(self.user) + LearningMentor.objects.create(mentor=self.user, course=self.course) + + # WHEN + response = self.client.get(self.url) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEquals(response.data["type"], "mentor") + + def test_trainer_cockpit(self) -> None: + # GIVEN + self.client.force_login(self.user) + course_session = create_course_session(course=self.course, title="Test Session") + + CourseSessionUser.objects.create( + user=self.user, + course_session=course_session, + role=CourseSessionUser.Role.EXPERT, + ) + + # WHEN + response = self.client.get(self.url) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEquals(response.data["type"], "trainer") diff --git a/server/vbv_lernwelt/api/user.py b/server/vbv_lernwelt/api/user.py new file mode 100644 index 00000000..e59f51d0 --- /dev/null +++ b/server/vbv_lernwelt/api/user.py @@ -0,0 +1,27 @@ +from django.shortcuts import get_object_or_404 +from rest_framework.decorators import api_view, permission_classes +from rest_framework.generics import get_object_or_404 +from rest_framework.permissions import IsAuthenticated +from rest_framework.response import Response + +from vbv_lernwelt.course.models import Course, CourseSessionUser +from vbv_lernwelt.learning_mentor.models import LearningMentor + + +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def get_cockpit_type(request, course_id: int): + course = get_object_or_404(Course, id=course_id) + + cockpit_type = None + + if LearningMentor.objects.filter(mentor=request.user, course=course).exists(): + cockpit_type = "mentor" + elif CourseSessionUser.objects.filter( + user=request.user, + course_session__course=course, + role=CourseSessionUser.Role.EXPERT, + ).exists(): + cockpit_type = "trainer" + + return Response({"type": cockpit_type}) diff --git a/server/vbv_lernwelt/course/models.py b/server/vbv_lernwelt/course/models.py index 78c88b84..c5dbeb7f 100644 --- a/server/vbv_lernwelt/course/models.py +++ b/server/vbv_lernwelt/course/models.py @@ -269,7 +269,6 @@ class CourseSessionUser(models.Model): class Role(models.TextChoices): MEMBER = "MEMBER", _("Teilnehmer") EXPERT = "EXPERT", _("Experte/Trainer") - TUTOR = "TUTOR", _("Lernbegleitung") role = models.CharField(choices=Role.choices, max_length=255, default=Role.MEMBER) diff --git a/server/vbv_lernwelt/learning_mentor/views.py b/server/vbv_lernwelt/learning_mentor/views.py index 0904b692..7c9c58b2 100644 --- a/server/vbv_lernwelt/learning_mentor/views.py +++ b/server/vbv_lernwelt/learning_mentor/views.py @@ -153,4 +153,20 @@ def remove_self_from_mentor(request, course_session_id: int, mentor_id: int): @api_view(["POST"]) @permission_classes([IsAuthenticated]) def accept_invitation(request, course_session_id: int): + course_session = get_object_or_404(CourseSession, id=course_session_id) + invitation = get_object_or_404( + MentorInvitation, id=request.data.get("invitation_id") + ) + + if invitation.participant.course_session != course_session: + return Response( + data={"message": "Invalid invitation"}, status=status.HTTP_400_BAD_REQUEST + ) + + mentor, _ = LearningMentor.objects.get_or_create( + mentor=request.user, course=course_session.course + ) + + mentor.participants.add(invitation.participant) + return Response({}) From 6bd913307c05991211d835d5b7db5a2279a11180 Mon Sep 17 00:00:00 2001 From: Reto Aebersold Date: Tue, 12 Dec 2023 10:01:11 +0100 Subject: [PATCH 16/61] feat: learning mentor mgmt UI --- .../components/header/MainNavigationBar.vue | 15 +++ .../learningMentor/MentorManagementPage.vue | 88 +++++++++++++++ client/src/router/index.ts | 5 + client/src/utils/route.ts | 6 ++ client/src/utils/utils.ts | 6 +- server/vbv_lernwelt/iam/permissions.py | 18 +++- server/vbv_lernwelt/iam/tests/__init__.py | 0 server/vbv_lernwelt/iam/tests/test_roles.py | 78 ++++++++++++++ .../learning_mentor/tests/test_invitation.py | 100 +++++++++++++++++- server/vbv_lernwelt/learning_mentor/views.py | 28 ++++- 10 files changed, 328 insertions(+), 16 deletions(-) create mode 100644 client/src/pages/learningMentor/MentorManagementPage.vue create mode 100644 server/vbv_lernwelt/iam/tests/__init__.py create mode 100644 server/vbv_lernwelt/iam/tests/test_roles.py diff --git a/client/src/components/header/MainNavigationBar.vue b/client/src/components/header/MainNavigationBar.vue index 836f8f40..8234380e 100644 --- a/client/src/components/header/MainNavigationBar.vue +++ b/client/src/components/header/MainNavigationBar.vue @@ -18,6 +18,7 @@ import CoursePreviewBar from "@/components/header/CoursePreviewBar.vue"; import { getCockpitUrl, getCompetenceNaviUrl, + getLearningMentorManagementUrl, getLearningPathUrl, getMediaCenterUrl, } from "@/utils/utils"; @@ -31,6 +32,7 @@ const notificationsStore = useNotificationsStore(); const { inCockpit, inCompetenceProfile, + inLearningMentor, inCourse, inLearningPath, inMediaLibrary, @@ -189,6 +191,19 @@ onMounted(() => { > {{ t("competences.title") }} + + + {{ t("a.Lernbegleitung") }} + diff --git a/client/src/pages/learningMentor/MentorManagementPage.vue b/client/src/pages/learningMentor/MentorManagementPage.vue new file mode 100644 index 00000000..e444cf55 --- /dev/null +++ b/client/src/pages/learningMentor/MentorManagementPage.vue @@ -0,0 +1,88 @@ + + + diff --git a/client/src/router/index.ts b/client/src/router/index.ts index a9bb8728..9a07c64d 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -121,6 +121,11 @@ const router = createRouter({ import("../pages/learningPath/learningContentPage/LearningContentPage.vue"), props: true, }, + { + path: "/course/:courseSlug/mentor", + component: () => import("@/pages/learningMentor/MentorManagementPage.vue"), + props: true, + }, { path: "/course/:courseSlug/cockpit", props: true, diff --git a/client/src/utils/route.ts b/client/src/utils/route.ts index 8f2a9b69..1ae3c477 100644 --- a/client/src/utils/route.ts +++ b/client/src/utils/route.ts @@ -22,6 +22,11 @@ export function useRouteLookups() { return regex.test(route.path); } + function inLearningMentor() { + const regex = new RegExp("/course/[^/]+/mentor"); + return regex.test(route.path); + } + function inMediaLibrary() { const regex = new RegExp("/course/[^/]+/media"); return regex.test(route.path); @@ -37,6 +42,7 @@ export function useRouteLookups() { inCockpit, inLearningPath, inCompetenceProfile, + inLearningMentor, inCourse, inAppointments: inAppointments, }; diff --git a/client/src/utils/utils.ts b/client/src/utils/utils.ts index f95ddc5a..75164a9d 100644 --- a/client/src/utils/utils.ts +++ b/client/src/utils/utils.ts @@ -14,7 +14,7 @@ function createCourseUrl(courseSlug: string | undefined, specificSub: string): s return "/"; } - if (["learn", "media", "competence", "cockpit"].includes(specificSub)) { + if (["learn", "media", "competence", "cockpit", "mentor"].includes(specificSub)) { return `/course/${courseSlug}/${specificSub}`; } return `/course/${courseSlug}`; @@ -36,6 +36,10 @@ export function getCockpitUrl(courseSlug: string | undefined): string { return createCourseUrl(courseSlug, "cockpit"); } +export function getLearningMentorManagementUrl(courseSlug: string | undefined): string { + return createCourseUrl(courseSlug, "mentor"); +} + export function getAssignmentTypeTitle(assignmentType: AssignmentType): string { const { t } = useTranslation(); diff --git a/server/vbv_lernwelt/iam/permissions.py b/server/vbv_lernwelt/iam/permissions.py index c82510fe..e0b0fd92 100644 --- a/server/vbv_lernwelt/iam/permissions.py +++ b/server/vbv_lernwelt/iam/permissions.py @@ -1,6 +1,7 @@ from vbv_lernwelt.core.models import User from vbv_lernwelt.course.models import Course, CourseSession, CourseSessionUser from vbv_lernwelt.course_session_group.models import CourseSessionGroup +from vbv_lernwelt.learning_mentor.models import LearningMentor from vbv_lernwelt.learnpath.models import LearningSequence @@ -137,8 +138,15 @@ def can_view_course_session(user: User, course_session: CourseSession) -> bool: def has_role_in_course(user: User, course: Course) -> bool: - """ - Test for regio leiter, member, trainer... - """ - ... - return True + if CourseSessionUser.objects.filter( + course_session__course=course, user=user + ).exists(): + return True + + if LearningMentor.objects.filter(course=course, mentor=user).exists(): + return True + + if CourseSessionGroup.objects.filter(course=course, supervisor=user).exists(): + return True + + return False diff --git a/server/vbv_lernwelt/iam/tests/__init__.py b/server/vbv_lernwelt/iam/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/server/vbv_lernwelt/iam/tests/test_roles.py b/server/vbv_lernwelt/iam/tests/test_roles.py new file mode 100644 index 00000000..088d145e --- /dev/null +++ b/server/vbv_lernwelt/iam/tests/test_roles.py @@ -0,0 +1,78 @@ +from django.test import TestCase + +from vbv_lernwelt.course.creators.test_utils import ( + add_course_session_user, + create_course, + create_course_session, + create_user, +) +from vbv_lernwelt.course.models import CourseSessionUser +from vbv_lernwelt.course_session_group.models import CourseSessionGroup +from vbv_lernwelt.iam.permissions import has_role_in_course +from vbv_lernwelt.learning_mentor.models import LearningMentor + + +class AnimalTestCase(TestCase): + def setUp(self): + self.course, _ = create_course("Test Course") + self.course_session = create_course_session( + course=self.course, title="Test Session" + ) + + self.user = create_user("user") + + def test_has_role_regional(self): + # GIVEN + csg = CourseSessionGroup.objects.create(name="Test Group", course=self.course) + csg.supervisor.add(self.user) + + # WHEN + has_role = has_role_in_course(user=self.user, course=self.course) + + # THEN + self.assertTrue(has_role) + + def test_has_role_course_session(self): + # GIVEN + add_course_session_user( + self.course_session, + self.user, + role=CourseSessionUser.Role.MEMBER, + ) + + # WHEN + has_role = has_role_in_course(user=self.user, course=self.course) + + # THEN + self.assertTrue(has_role) + + def test_has_role_mentor(self): + # GIVEN + LearningMentor.objects.create( + mentor=self.user, + course=self.course, + ) + + # WHEN + has_role = has_role_in_course(user=self.user, course=self.course) + + # THEN + self.assertTrue(has_role) + + def test_no_role(self): + # GIVEN + other_course, _ = create_course("Other Test Course") + other_course_session = create_course_session( + course=other_course, title="Other Test Session" + ) + add_course_session_user( + other_course_session, + self.user, + role=CourseSessionUser.Role.MEMBER, + ) + + # WHEN + has_role = has_role_in_course(user=self.user, course=self.course) + + # THEN + self.assertFalse(has_role) diff --git a/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py b/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py index 34558583..182787f9 100644 --- a/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py +++ b/server/vbv_lernwelt/learning_mentor/tests/test_invitation.py @@ -11,7 +11,7 @@ from vbv_lernwelt.course.creators.test_utils import ( create_user, ) from vbv_lernwelt.course.models import CourseSessionUser -from vbv_lernwelt.learning_mentor.models import MentorInvitation +from vbv_lernwelt.learning_mentor.models import LearningMentor, MentorInvitation from vbv_lernwelt.notify.email.email_services import EmailTemplate @@ -129,17 +129,107 @@ class LearningMentorInvitationTest(APITestCase): self.assertEqual(response.status_code, status.HTTP_204_NO_CONTENT) self.assertFalse(MentorInvitation.objects.filter(id=invitation.id).exists()) - def test_accept_invitation(self) -> None: + def test_accept_invitation_invalid_course(self) -> None: # GIVEN + other_course, _ = create_course("Other Test Course") + other_course_session = create_course_session( + course=other_course, title="Other Test Session" + ) + participant_cs_user = add_course_session_user( + self.course_session, + self.participant, + role=CourseSessionUser.Role.MEMBER, + ) + invitee = create_user("invitee") + invitation = MentorInvitation.objects.create( + participant=participant_cs_user, email=invitee.email + ) self.client.force_login(invitee) accept_url = reverse( - "create_invitation", kwargs={"course_session_id": self.course_session.id} + "accept_invitation", kwargs={"course_session_id": other_course_session.id} ) # WHEN - response = self.client.get(accept_url) + response = self.client.post(accept_url, data={"invitation_id": invitation.id}) # THEN - self.assertEqual(response.status_code, status.HTTP_403_FORBIDDEN) + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + { + "message": "Invalid invitation", + "code": "invalidInvitation", + }, + ) + + def test_accept_invitation_role_collision(self) -> None: + # GIVEN + participant_cs_user = add_course_session_user( + self.course_session, + self.participant, + role=CourseSessionUser.Role.MEMBER, + ) + + invitee = create_user("invitee") + invitation = MentorInvitation.objects.create( + participant=participant_cs_user, email=invitee.email + ) + # Make invitee a trainer + add_course_session_user( + self.course_session, + invitee, + role=CourseSessionUser.Role.EXPERT, + ) + + self.client.force_login(invitee) + + accept_url = reverse( + "accept_invitation", kwargs={"course_session_id": self.course_session.id} + ) + + # WHEN + response = self.client.post(accept_url, data={"invitation_id": invitation.id}) + + # THEN + self.assertEqual(response.status_code, status.HTTP_400_BAD_REQUEST) + self.assertEqual( + response.data, + { + "message": "User already has a role in this course", + "code": "existingRole", + }, + ) + + def test_accept_invitation(self) -> None: + # GIVEN + participant_cs_user = add_course_session_user( + self.course_session, + self.participant, + role=CourseSessionUser.Role.MEMBER, + ) + + invitee = create_user("invitee") + invitation = MentorInvitation.objects.create( + participant=participant_cs_user, email=invitee.email + ) + + self.client.force_login(invitee) + + accept_url = reverse( + "accept_invitation", kwargs={"course_session_id": self.course_session.id} + ) + + # WHEN + response = self.client.post(accept_url, data={"invitation_id": invitation.id}) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertFalse(MentorInvitation.objects.filter(id=invitation.id).exists()) + self.assertTrue( + LearningMentor.objects.filter( + mentor=invitee, course=self.course, participants=participant_cs_user + ).exists() + ) + self.assertEqual(response.data["id"], str(self.participant.id)) diff --git a/server/vbv_lernwelt/learning_mentor/views.py b/server/vbv_lernwelt/learning_mentor/views.py index 7c9c58b2..3ee73690 100644 --- a/server/vbv_lernwelt/learning_mentor/views.py +++ b/server/vbv_lernwelt/learning_mentor/views.py @@ -8,7 +8,7 @@ from rest_framework.response import Response from vbv_lernwelt.core.serializers import UserSerializer from vbv_lernwelt.course.models import CourseSession, CourseSessionUser -from vbv_lernwelt.iam.permissions import is_course_session_member +from vbv_lernwelt.iam.permissions import has_role_in_course, is_course_session_member from vbv_lernwelt.learning_mentor.content.praxis_assignment import ( get_praxis_assignments, ) @@ -160,13 +160,31 @@ def accept_invitation(request, course_session_id: int): if invitation.participant.course_session != course_session: return Response( - data={"message": "Invalid invitation"}, status=status.HTTP_400_BAD_REQUEST + data={"message": "Invalid invitation", "code": "invalidInvitation"}, + status=status.HTTP_400_BAD_REQUEST, ) - mentor, _ = LearningMentor.objects.get_or_create( + if LearningMentor.objects.filter( mentor=request.user, course=course_session.course - ) + ).exists(): + mentor = LearningMentor.objects.get( + mentor=request.user, course=course_session.course + ) + else: + if has_role_in_course(request.user, course_session.course): + return Response( + data={ + "message": "User already has a role in this course", + "code": "existingRole", + }, + status=status.HTTP_400_BAD_REQUEST, + ) + + mentor = LearningMentor.objects.create( + mentor=request.user, course=course_session.course + ) mentor.participants.add(invitation.participant) + invitation.delete() - return Response({}) + return Response(UserSerializer(invitation.participant.user).data) From 9eb2bbceba140c2556f7db2761e647dd6485b710 Mon Sep 17 00:00:00 2001 From: Livio Bieri Date: Tue, 12 Dec 2023 10:24:11 +0100 Subject: [PATCH 17/61] chore: removes CockpitParentPage.vue --- .../src/pages/cockpit/CockpitParentPage.vue | 55 ------------------- .../pages/cockpit/CockpitUserCirclePage.vue | 5 +- .../pages/cockpit/CockpitUserProfilePage.vue | 7 ++- client/src/pages/cockpit/FeedbackPage.vue | 5 +- .../AssignmentEvaluationPage.vue | 4 +- .../assignmentsPage/AssignmentsPage.vue | 4 +- .../pages/cockpit/cockpitPage/CockpitPage.vue | 5 +- .../pages/cockpit/cockpitPage/composables.ts | 36 ++++++++++++ .../cockpit/documentPage/DocumentPage.vue | 5 +- client/src/router/index.ts | 2 - 10 files changed, 62 insertions(+), 66 deletions(-) delete mode 100644 client/src/pages/cockpit/CockpitParentPage.vue create mode 100644 client/src/pages/cockpit/cockpitPage/composables.ts diff --git a/client/src/pages/cockpit/CockpitParentPage.vue b/client/src/pages/cockpit/CockpitParentPage.vue deleted file mode 100644 index 1c29046b..00000000 --- a/client/src/pages/cockpit/CockpitParentPage.vue +++ /dev/null @@ -1,55 +0,0 @@ - - - - - diff --git a/client/src/pages/cockpit/CockpitUserCirclePage.vue b/client/src/pages/cockpit/CockpitUserCirclePage.vue index e7d2889f..eb892680 100644 --- a/client/src/pages/cockpit/CockpitUserCirclePage.vue +++ b/client/src/pages/cockpit/CockpitUserCirclePage.vue @@ -3,6 +3,7 @@ import CirclePage from "@/pages/learningPath/circlePage/CirclePage.vue"; import * as log from "loglevel"; import { computed, onMounted } from "vue"; import { useCourseSessionDetailQuery } from "@/composables"; +import { useExpertCockpitPageData } from "@/pages/cockpit/cockpitPage/composables"; const props = defineProps<{ userId: string; @@ -12,6 +13,8 @@ const props = defineProps<{ log.debug("CockpitUserCirclePage created", props.userId, props.circleSlug); +const { loading } = useExpertCockpitPageData(props.courseSlug); + onMounted(async () => { log.debug("CockpitUserCirclePage mounted"); }); @@ -25,7 +28,7 @@ const user = computed(() => {