diff --git a/client/src/components/cockpit/profile/CockpitProfileContent.vue b/client/src/components/cockpit/profile/CockpitProfileContent.vue new file mode 100644 index 00000000..77eb78d1 --- /dev/null +++ b/client/src/components/cockpit/profile/CockpitProfileContent.vue @@ -0,0 +1,10 @@ + diff --git a/client/src/pages/cockpit/CockpitUserProfilePage.vue b/client/src/pages/cockpit/CockpitUserProfilePage.vue deleted file mode 100644 index 88df5bd4..00000000 --- a/client/src/pages/cockpit/CockpitUserProfilePage.vue +++ /dev/null @@ -1,116 +0,0 @@ - - - - - diff --git a/client/src/pages/cockpit/cockpitPage/mentor/MentorParticipants.vue b/client/src/pages/cockpit/cockpitPage/mentor/MentorParticipants.vue index c8fd6d15..5356d283 100644 --- a/client/src/pages/cockpit/cockpitPage/mentor/MentorParticipants.vue +++ b/client/src/pages/cockpit/cockpitPage/mentor/MentorParticipants.vue @@ -27,9 +27,12 @@ const { summary } = useMentorCockpit(courseSession.value.id); {{ participant.email }} - - - + + {{ $t("cockpit.profileLink") }} + diff --git a/client/src/pages/cockpit/profilePage/CockpitUserProfilePage.vue b/client/src/pages/cockpit/profilePage/CockpitUserProfilePage.vue new file mode 100644 index 00000000..e06f7f96 --- /dev/null +++ b/client/src/pages/cockpit/profilePage/CockpitUserProfilePage.vue @@ -0,0 +1,74 @@ + + + diff --git a/client/src/pages/cockpit/profilePage/LearningPathProfilePage.vue b/client/src/pages/cockpit/profilePage/LearningPathProfilePage.vue new file mode 100644 index 00000000..c094e729 --- /dev/null +++ b/client/src/pages/cockpit/profilePage/LearningPathProfilePage.vue @@ -0,0 +1,73 @@ + + + diff --git a/client/src/pages/learningPath/circlePage/LearningSequence.vue b/client/src/pages/learningPath/circlePage/LearningSequence.vue index 2f52f837..2457c591 100644 --- a/client/src/pages/learningPath/circlePage/LearningSequence.vue +++ b/client/src/pages/learningPath/circlePage/LearningSequence.vue @@ -32,6 +32,7 @@ type Props = { learningSequence: LearningSequence; circle: CircleType; readonly?: boolean; + hideLinks?: boolean; }; const props = withDefaults(defineProps(), { @@ -229,7 +230,7 @@ function checkboxIconUncheckedTailwindClass(lc: LearningContent) {
{{ @@ -298,11 +299,7 @@ function checkboxIconUncheckedTailwindClass(lc: LearningContent) {
{{ $t("a.Selbsteinschätzung") }}
- - - - diff --git a/client/src/pages/learningPath/learningContentPage/LearningContentParent.vue b/client/src/pages/learningPath/learningContentPage/LearningContentParent.vue index 012f966f..e33113fd 100644 --- a/client/src/pages/learningPath/learningContentPage/LearningContentParent.vue +++ b/client/src/pages/learningPath/learningContentPage/LearningContentParent.vue @@ -64,8 +64,8 @@ function handleFinishedLearningContent() { props.learningContent, props.circle, previousRoute, - (lc: LearningContentWithCompletion) => { - courseCompletionData.markCompletion(lc, "SUCCESS"); + async (lc: LearningContentWithCompletion) => { + await courseCompletionData.markCompletion(lc, "SUCCESS"); } ); } diff --git a/client/src/router/index.ts b/client/src/router/index.ts index 2e0deef5..44eeb299 100644 --- a/client/src/router/index.ts +++ b/client/src/router/index.ts @@ -221,9 +221,19 @@ const router = createRouter({ }, { path: "profile/:userId", - component: () => import("@/pages/cockpit/CockpitUserProfilePage.vue"), + component: () => + import("@/pages/cockpit/profilePage/CockpitUserProfilePage.vue"), props: true, name: "cockpitUserProfile", + children: [ + { + path: "learning-path", + component: () => + import("@/pages/cockpit/profilePage/LearningPathProfilePage.vue"), + props: true, + name: "cockpitProfileLearningPath", + }, + ], }, { path: "profile/:userId/:circleSlug", diff --git a/client/src/stores/circle.ts b/client/src/stores/circle.ts index 1bebc5db..5756c775 100644 --- a/client/src/stores/circle.ts +++ b/client/src/stores/circle.ts @@ -73,16 +73,18 @@ export const useCircleStore = defineStore({ }); } }, - continueFromLearningContent( + async continueFromLearningContent( currentLearningContent: LearningContentWithCompletion, circle: CircleType, returnRoute?: RouteLocationNormalized, - markCompletionFn?: (learningContent: LearningContentWithCompletion) => void + markCompletionFn?: ( + learningContent: LearningContentWithCompletion + ) => Promise ) { if (currentLearningContent) { if (currentLearningContent.can_user_self_toggle_course_completion) { if (markCompletionFn) { - markCompletionFn(currentLearningContent); + await markCompletionFn(currentLearningContent); } } this.closeLearningContent(currentLearningContent, circle, returnRoute); diff --git a/cypress/e2e/circle.cy.js b/cypress/e2e/circle.cy.js index 98d41b73..de1185a1 100644 --- a/cypress/e2e/circle.cy.js +++ b/cypress/e2e/circle.cy.js @@ -9,71 +9,71 @@ describe("circle.cy.js", () => { }); it("can open circle page", () => { - cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug"); + cy.get("[data-cy=\"circle-title\"]").should("contain", "Fahrzeug"); }); it("can toggle learning content", () => { - cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug"); + cy.get("[data-cy=\"circle-title\"]").should("contain", "Fahrzeug"); cy.get( - '[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox"]' + "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox\"]" ).should("have.class", "cy-unchecked"); cy.get( - '[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox"]' + "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox\"]" ).click(); cy.get( - '[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox"]' + "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox\"]" ).should("have.class", "cy-checked"); // completion data should still be there after reload cy.reload(); cy.get( - '[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox"]' + "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox\"]" ).should("have.class", "cy-checked"); }); it("can open learning contents and complete them by continuing", () => { cy.get( - '[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-verschaffe-dir-einen-überblick"]' + "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-verschaffe-dir-einen-überblick\"]" ).click(); - cy.get('[data-cy="lc-title"]').should( + cy.get("[data-cy=\"lc-title\"]").should( "contain", "Verschaffe dir einen Überblick" ); - cy.get('[data-cy="complete-and-continue"]').click({ force: true }); - cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug"); + cy.get("[data-cy=\"complete-and-continue\"]").click({ force: true }); + cy.get("[data-cy=\"circle-title\"]").should("contain", "Fahrzeug"); - cy.get('[data-cy="ls-continue-button"]').click(); - cy.get('[data-cy="lc-title"]').should( + cy.get("[data-cy=\"ls-continue-button\"]").click({ force: true }); + cy.get("[data-cy=\"lc-title\"]").should( "contain", "Handlungsfeld «Fahrzeug»" ); - cy.get('[data-cy="complete-and-continue"]').click({ force: true }); - cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug"); + cy.get("[data-cy=\"complete-and-continue\"]").click({ force: true }); + cy.get("[data-cy=\"circle-title\"]").should("contain", "Fahrzeug"); cy.get( - '[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-verschaffe-dir-einen-überblick-checkbox"]' + "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-verschaffe-dir-einen-überblick-checkbox\"]" ).should("have.class", "cy-checked"); cy.get( - '[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox"]' + "[data-cy=\"test-lehrgang-lp-circle-fahrzeug-lc-handlungsfeld-fahrzeug-checkbox\"]" ).should("have.class", "cy-checked"); }); it("continue button works", () => { - cy.get('[data-cy="ls-continue-button"]').should("contain", "Los geht's"); - cy.get('[data-cy="ls-continue-button"]').click(); + cy.get("[data-cy=\"ls-continue-button\"]").should("contain", "Los geht's"); + cy.get("[data-cy=\"ls-continue-button\"]").click(); - cy.get('[data-cy="lc-title"]').should( + cy.get("[data-cy=\"lc-title\"]").should( "contain", "Verschaffe dir einen Überblick" ); - cy.get('[data-cy="complete-and-continue"]').click({ force: true }); + cy.get("[data-cy=\"complete-and-continue\"]").click({ force: true }); - cy.get('[data-cy="ls-continue-button"]').should("contain", "Weiter geht's"); - cy.get('[data-cy="ls-continue-button"]').click(); - cy.get('[data-cy="lc-title"]').should( + cy.get("[data-cy=\"ls-continue-button\"]").should("contain", "Weiter geht's"); + cy.get("[data-cy=\"ls-continue-button\"]").click(); + cy.get("[data-cy=\"lc-title\"]").should( "contain", "Handlungsfeld «Fahrzeug»" ); @@ -81,43 +81,43 @@ describe("circle.cy.js", () => { it("can open learning content by url", () => { cy.visit("/course/test-lehrgang/learn/fahrzeug/handlungsfeld-fahrzeug"); - cy.get('[data-cy="lc-title"]').should( + cy.get("[data-cy=\"lc-title\"]").should( "contain", "Handlungsfeld «Fahrzeug»" ); - cy.get('[data-cy="close-learning-content"]').click(); - cy.get('[data-cy="circle-title"]').should("contain", "Fahrzeug"); + cy.get("[data-cy=\"close-learning-content\"]").click(); + cy.get("[data-cy=\"circle-title\"]").should("contain", "Fahrzeug"); }); it("checks number of sequences and contents", () => { - cy.get('[data-cy="lp-learning-sequence"]').should("have.length", 3); - cy.get('[data-cy="lp-learning-sequence"]') + cy.get("[data-cy=\"lp-learning-sequence\"]").should("have.length", 3); + cy.get("[data-cy=\"lp-learning-sequence\"]") .first() .should("contain", "Vorbereitung"); - cy.get('[data-cy="lp-learning-sequence"]') + cy.get("[data-cy=\"lp-learning-sequence\"]") .eq(1) .should("contain", "Training"); - cy.get('[data-cy="lp-learning-sequence"]') + cy.get("[data-cy=\"lp-learning-sequence\"]") .last() .should("contain", "Transfer"); - cy.get('[data-cy="lp-learning-content"]').should("have.length", 10); - cy.get('[data-cy="lp-learning-content"]') + cy.get("[data-cy=\"lp-learning-content\"]").should("have.length", 10); + cy.get("[data-cy=\"lp-learning-content\"]") .first() .should("contain", "Verschaffe dir einen Überblick"); - cy.get('[data-cy="lp-learning-content"]') + cy.get("[data-cy=\"lp-learning-content\"]") .eq(4) .should("contain", "Präsenzkurs Fahrzeug"); - cy.get('[data-cy="lp-learning-content"]') + cy.get("[data-cy=\"lp-learning-content\"]") .eq(7) .should("contain", "Reflexion"); - cy.get('[data-cy="lp-learning-content"]') + cy.get("[data-cy=\"lp-learning-content\"]") .last() .should("contain", "Feedback"); cy.visit("/course/test-lehrgang/learn/reisen"); - cy.get('[data-cy="lp-learning-sequence"]').should("have.length", 3); - cy.get('[data-cy="lp-learning-content"]').should("have.length", 9); + cy.get("[data-cy=\"lp-learning-sequence\"]").should("have.length", 3); + cy.get("[data-cy=\"lp-learning-content\"]").should("have.length", 9); }); }); diff --git a/server/config/urls.py b/server/config/urls.py index 6cd5c4ad..56b9ad07 100644 --- a/server/config/urls.py +++ b/server/config/urls.py @@ -12,7 +12,12 @@ from django_ratelimit.exceptions import Ratelimited from graphene_django.views import GraphQLView from vbv_lernwelt.api.directory import list_entities -from vbv_lernwelt.api.user import get_cockpit_type, me_user_view, post_avatar +from vbv_lernwelt.api.user import ( + get_cockpit_type, + get_profile, + me_user_view, + post_avatar, +) from vbv_lernwelt.assignment.views import request_assignment_completion_status from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt from vbv_lernwelt.core.schema import schema @@ -102,6 +107,7 @@ urlpatterns = [ re_path(r'api/core/me/$', me_user_view, name='me_user_view'), re_path(r'api/core/avatar/$', post_avatar, name='post_avatar'), re_path(r'api/core/entities/$', list_entities, name='list_entities'), + path(r'api/core/profile//', get_profile, name='get_profile_view'), re_path(r'api/core/login/$', django_view_authentication_exempt(vue_login), name='vue_login'), diff --git a/server/vbv_lernwelt/api/tests/test_profile.py b/server/vbv_lernwelt/api/tests/test_profile.py new file mode 100644 index 00000000..4c73b50e --- /dev/null +++ b/server/vbv_lernwelt/api/tests/test_profile.py @@ -0,0 +1,60 @@ +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 ProfileViewTest(APITestCase): + def setUp(self) -> None: + self.course, _ = create_course("Test Course") + self.course_session = create_course_session( + course=self.course, title="Test Session" + ) + + self.user = create_user("user") + add_course_session_user( + self.course_session, + self.user, + role=CourseSessionUser.Role.MEMBER, + ) + + self.client.force_login(self.user) + + def test_user_profile(self) -> None: + # GIVEN + url = reverse( + "get_profile_view", + kwargs={ + "course_session_id": self.course_session.id, + "user_id": self.user.id, + }, + ) + + # WHEN + response = self.client.get(url) + + # THEN + self.assertEqual(response.status_code, status.HTTP_200_OK) + profile = response.data + self.assertEqual( + profile, + { + "id": str(self.user.id), + "first_name": self.user.first_name, + "last_name": self.user.last_name, + "email": self.user.email, + "username": self.user.username, + "avatar_url": "/static/avatars/myvbv-default-avatar.png", + "organisation": None, + "is_superuser": False, + "course_session_experts": [], + "language": "de", + }, + ) diff --git a/server/vbv_lernwelt/api/user.py b/server/vbv_lernwelt/api/user.py index b984ba23..4f865a8f 100644 --- a/server/vbv_lernwelt/api/user.py +++ b/server/vbv_lernwelt/api/user.py @@ -7,6 +7,7 @@ from rest_framework.response import Response from vbv_lernwelt.core.serializers import UserSerializer from vbv_lernwelt.course.models import Course, CourseSessionUser from vbv_lernwelt.course_session_group.models import CourseSessionGroup +from vbv_lernwelt.iam.permissions import can_view_profile from vbv_lernwelt.learning_mentor.models import LearningMentor from vbv_lernwelt.media_files.models import UserImage @@ -61,6 +62,19 @@ def get_cockpit_type(request, course_id: int): return Response({"type": cockpit_type}) +@api_view(["GET"]) +@permission_classes([IsAuthenticated]) +def get_profile(request, course_session_id: int, user_id: str): + course_session_user = get_object_or_404( + CourseSessionUser, course_session_id=course_session_id, user_id=user_id + ) + + if not can_view_profile(request.user, course_session_user): + return Response(status=403) + + return Response(UserSerializer(course_session_user.user).data) + + @api_view(["POST"]) @permission_classes([IsAuthenticated]) def post_avatar(request): diff --git a/server/vbv_lernwelt/course/views.py b/server/vbv_lernwelt/course/views.py index 85b67714..6c2db3ee 100644 --- a/server/vbv_lernwelt/course/views.py +++ b/server/vbv_lernwelt/course/views.py @@ -18,11 +18,11 @@ from vbv_lernwelt.course_session_group.models import CourseSessionGroup from vbv_lernwelt.files.models import UploadFile from vbv_lernwelt.files.services import FileDirectUploadService from vbv_lernwelt.iam.permissions import ( + can_view_course_completions, course_sessions_for_user_qs, has_course_access, has_course_access_by_page_request, is_circle_expert, - is_course_session_expert, ) from vbv_lernwelt.learning_mentor.models import LearningMentor @@ -76,8 +76,8 @@ def request_course_completion(request, course_session_id): @api_view(["GET"]) def request_course_completion_for_user(request, course_session_id, user_id): - if request.user.id == user_id or is_course_session_expert( - request.user, course_session_id + if can_view_course_completions( + user=request.user, course_session_id=course_session_id, target_user_id=user_id ): return _request_course_completion(course_session_id, user_id) raise PermissionDenied() diff --git a/server/vbv_lernwelt/iam/permissions.py b/server/vbv_lernwelt/iam/permissions.py index 01164834..110be65f 100644 --- a/server/vbv_lernwelt/iam/permissions.py +++ b/server/vbv_lernwelt/iam/permissions.py @@ -52,12 +52,14 @@ def is_course_session_expert(user, course_session_id: int): if user.is_superuser: return True + course_session = CourseSession.objects.get(id=course_session_id) + is_supervisor = CourseSessionGroup.objects.filter( - supervisor=user, course_session__id=course_session_id + supervisor=user, course_session=course_session ).exists() is_expert = CourseSessionUser.objects.filter( - course_session_id=course_session_id, + course_session=course_session, user=user, role=CourseSessionUser.Role.EXPERT, ).exists() @@ -174,3 +176,34 @@ def has_role_in_course(user: User, course: Course) -> bool: return True return False + + +def can_view_profile(user: User, profile_user: CourseSessionUser) -> bool: + if user.is_superuser: + return True + + if user == profile_user.user: + return True + + if is_course_session_expert(user, profile_user.course_session.id) or is_user_mentor( + mentor=user, + participant_user_id=profile_user.user.id, + course_session_id=profile_user.course_session.id, + ): + return True + + return False + + +def can_view_course_completions( + user: User, course_session_id: int, target_user_id: str +) -> bool: + return ( + user.id == target_user_id + or is_course_session_expert(user=user, course_session_id=course_session_id) + or is_user_mentor( + mentor=user, + participant_user_id=target_user_id, + course_session_id=course_session_id, + ) + ) diff --git a/server/vbv_lernwelt/iam/tests/test_experts.py b/server/vbv_lernwelt/iam/tests/test_experts.py new file mode 100644 index 00000000..973bb368 --- /dev/null +++ b/server/vbv_lernwelt/iam/tests/test_experts.py @@ -0,0 +1,66 @@ +from django.test import TestCase + +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.course_session_group.models import CourseSessionGroup +from vbv_lernwelt.iam.permissions import is_course_session_expert + + +class ExpertTestCase(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_member(self): + # GIVEN + csu = CourseSessionUser.objects.create( + course_session=self.course_session, + user=self.user, + role=CourseSessionUser.Role.MEMBER, + ) + + # WHEN + is_expert = is_course_session_expert( + user=csu.user, course_session_id=self.course_session.id + ) + + # THEN + self.assertFalse(is_expert) + + def test_supervisor(self): + # GIVEN + csg = CourseSessionGroup.objects.create(name="Test Group", course=self.course) + csg.course_session.add(self.course_session) + csg.supervisor.add(self.user) + + # WHEN + is_expert = is_course_session_expert( + user=self.user, course_session_id=self.course_session.id + ) + + # THEN + self.assertTrue(is_expert) + + def test_expert(self): + # GIVEN + csu = CourseSessionUser.objects.create( + course_session=self.course_session, + user=self.user, + role=CourseSessionUser.Role.EXPERT, + ) + + # WHEN + is_expert = is_course_session_expert( + user=csu.user, course_session_id=self.course_session.id + ) + + # THEN + self.assertTrue(is_expert)