feat: cockpit user profile

This commit is contained in:
Reto Aebersold 2024-01-15 11:33:45 +01:00
parent a154341fae
commit 6a985ce607
9 changed files with 288 additions and 122 deletions

View File

@ -0,0 +1,10 @@
<template>
<div>
<div class="flex-none border-r bg-white p-4 lg:p-8">
<slot name="side"></slot>
</div>
<div class="flex-1 p-4 lg:p-8">
<slot name="main"></slot>
</div>
</div>
</template>

View File

@ -1,116 +0,0 @@
<script setup lang="ts">
import * as log from "loglevel";
import { computed, onMounted } from "vue";
import CompetenceDetail from "@/pages/competence/ActionCompetenceDetail.vue";
import LearningPathPathView from "@/pages/learningPath/learningPathPage/LearningPathPathView.vue";
import {
useCourseDataWithCompletion,
useCourseSessionDetailQuery,
} from "@/composables";
import { useExpertCockpitPageData } from "@/pages/cockpit/cockpitPage/composables";
const props = defineProps<{
userId: string;
courseSlug: string;
}>();
log.debug("CockpitUserProfilePage created", props.userId);
const { loading } = useExpertCockpitPageData(props.courseSlug);
const courseCompletionData = useCourseDataWithCompletion(
props.courseSlug,
props.userId
);
onMounted(async () => {
log.debug("CockpitUserProfilePage mounted");
});
const lpQueryResult = useCourseDataWithCompletion(props.courseSlug, props.userId);
const learningPath = computed(() => {
return lpQueryResult.learningPath.value;
});
const { findUser } = useCourseSessionDetailQuery();
const user = computed(() => {
return findUser(props.userId);
});
function setActiveClasses(isActive: boolean) {
return isActive ? ["border-blue-900", "border-b-2"] : ["text-bg-900"];
}
</script>
<template>
<div v-if="!loading" class="bg-gray-200">
<div v-if="user" class="container-large">
<nav class="py-4 pb-4">
<router-link
class="btn-text inline-flex items-center pl-0"
:to="`/course/${props.courseSlug}/cockpit`"
>
<it-icon-arrow-left />
<span>{{ $t("general.back") }}</span>
</router-link>
</nav>
<header class="mb-12 flex flex-row items-center">
<img class="mr-8 h-44 w-44 rounded-full" :src="user.avatar_url" />
<div>
<h1 class="mb-2">{{ user.first_name }} {{ user.last_name }}</h1>
<p class="mb-2">{{ user.email }}</p>
<p class="bg-message bg-[center_left_-4px] bg-no-repeat pl-6">
{{ $t("messages.sendMessage") }}
</p>
</div>
</header>
<main>
<div v-if="learningPath" class="mb-8 w-full bg-white pb-1 pt-2">
<!-- TODO: rework this section with next redesign -->
<LearningPathPathView
:use-mobile-layout="false"
:hide-buttons="true"
:learning-path="learningPath"
:next-learning-content="undefined"
:override-circle-url-base="`/course/${props.courseSlug}/cockpit/profile/${props.userId}`"
></LearningPathPathView>
</div>
<ul class="mb-5 flex flex-row border-b-2">
<li class="relative top-px mr-12 pb-3" :class="setActiveClasses(true)">
<button>{{ $t("competences.competences") }}</button>
</li>
<li class="mr-12">
<button>{{ $t("general.transferTask_other") }}</button>
</li>
<li class="mr-12">
<button>{{ $t("general.exam_other") }}</button>
</li>
<li class="mr-12">
<button>{{ $t("general.certificate_other") }}</button>
</li>
</ul>
<div>
<ul class="bg-white px-8">
<li
v-for="competence in courseCompletionData.actionCompetences.value ?? []"
:key="competence.id"
class="border-b border-gray-500 p-8 last:border-0"
>
<CompetenceDetail
:competence="competence"
:course-slug="props.courseSlug"
:show-assess-again="false"
:is-inline="true"
></CompetenceDetail>
</li>
</ul>
</div>
</main>
</div>
</div>
</template>
<style scoped></style>

View File

@ -0,0 +1,74 @@
<script setup lang="ts">
import { onMounted, ref } from "vue";
import { useRoute, useRouter } from "vue-router";
import { useTranslation } from "i18next-vue";
import { useFetch } from "@vueuse/core";
import { useCurrentCourseSession } from "@/composables";
const props = defineProps<{
userId: string;
courseSlug: string;
}>();
const { t } = useTranslation();
const pages = ref([
{ label: t("general.learningPath"), route: "cockpitProfileLearningPath" },
]);
const courseSession = useCurrentCourseSession();
const { data: user } = useFetch(
`/api/core/profile/${courseSession.value.id}/${props.userId}`
).json();
const route = useRoute();
const router = useRouter();
onMounted(() => {
// if current route name not in pages, redirect to first page
if (route.name && !pages.value.find((page) => page.route === route.name)) {
router.push({
name: pages.value[0].route,
params: { userId: props.userId, courseSlug: props.courseSlug },
});
}
});
</script>
<template>
<div v-if="user" class="flex flex-col bg-gray-200">
<div class="relative border-b bg-white shadow-md">
<div class="container-large pb-0">
<router-link
class="btn-text inline-flex items-center pl-0"
:to="`/course/${props.courseSlug}/cockpit`"
>
<it-icon-arrow-left />
<span>{{ $t("general.back") }}</span>
</router-link>
<div class="mb-12 mt-2 flex items-center">
<img class="mr-8 h-48 w-48 rounded-full" :src="user.avatar_url" />
<div class="flex flex-col">
<h2 class="mb-2">{{ user.first_name }} {{ user.last_name }}</h2>
<p class="mb-2">{{ user.email }}</p>
<p class="text-gray-800">{{ $t("a.Teilnehmer") }}</p>
</div>
</div>
</div>
<ul class="flex flex-row px-4 lg:px-8">
<li
v-for="page in pages"
:key="page.route"
class="relative top-px mr-12 pb-3"
:class="[route.name === page.route ? 'border-b-2 border-blue-900 pb-3' : '']"
>
<router-link :to="{ name: page.route }">
{{ page.label }}
</router-link>
</li>
</ul>
</div>
<router-view class="flex flex-grow py-0" />
</div>
</template>

View File

@ -0,0 +1,72 @@
<script setup lang="ts">
import { calculateCircleSectorData } from "@/pages/learningPath/learningPathPage/utils";
import { useCourseDataWithCompletion } from "@/composables";
import CockpitProfileContent from "@/components/cockpit/profile/CockpitProfileContent.vue";
import LearningPathCircle from "@/pages/learningPath/learningPathPage/LearningPathCircle.vue";
import LearningSequence from "@/pages/learningPath/circlePage/LearningSequence.vue";
import { ref, watch } from "vue";
import type { CircleType } from "@/types";
const props = defineProps<{
userId: string;
courseSlug: string;
}>();
const selectedCircle = ref();
const lpQueryResult = useCourseDataWithCompletion(props.courseSlug, props.userId);
function selectCircle(circle: CircleType) {
selectedCircle.value = circle;
}
watch(lpQueryResult.learningPath, () => {
if (lpQueryResult.learningPath?.value?.topics?.length) {
selectCircle(lpQueryResult.learningPath.value.topics[0].circles[0]);
}
});
</script>
<template>
<CockpitProfileContent>
<template #side>
<div
v-for="topic in lpQueryResult.learningPath?.value?.topics ?? []"
:key="topic.id"
class="mb-4"
>
<h4 class="mb-1 font-semibold text-gray-800">
{{ topic.title }}
</h4>
<button
v-for="circle in topic.circles"
:key="circle.id"
class="flex w-full items-center space-x-2 p-2 pr-4 hover:bg-gray-200 lg:pr-8"
:class="{ 'bg-gray-200': selectedCircle === circle }"
@click="selectCircle(circle)"
>
<LearningPathCircle
:sectors="calculateCircleSectorData(circle)"
class="h-10 w-10 snap-center rounded-full bg-white p-0.5"
></LearningPathCircle>
<span>{{ circle.title }}</span>
</button>
</div>
</template>
<template #main>
<ol v-if="selectedCircle" class="flex-auto bg-gray-200 px-6 py-4 lg:px-16">
<li
v-for="learningSequence in selectedCircle.learning_sequences ?? []"
:key="learningSequence.id"
>
<LearningSequence
:course-slug="props.courseSlug"
:circle="selectedCircle"
:learning-sequence="learningSequence"
readonly
></LearningSequence>
</li>
</ol>
</template>
</CockpitProfileContent>
</template>

View File

@ -221,9 +221,19 @@ const router = createRouter({
}, },
{ {
path: "profile/:userId", path: "profile/:userId",
component: () => import("@/pages/cockpit/CockpitUserProfilePage.vue"), component: () =>
import("@/pages/cockpit/profilePage/CockpitUserProfilePage.vue"),
props: true, props: true,
name: "cockpitUserProfile", name: "cockpitUserProfile",
children: [
{
path: "learning-path",
component: () =>
import("@/pages/cockpit/profilePage/LearningPathProfilePage.vue"),
props: true,
name: "cockpitProfileLearningPath",
},
],
}, },
{ {
path: "profile/:userId/:circleSlug", path: "profile/:userId/:circleSlug",

View File

@ -12,7 +12,7 @@ from django_ratelimit.exceptions import Ratelimited
from graphene_django.views import GraphQLView from graphene_django.views import GraphQLView
from vbv_lernwelt.api.directory import list_entities from vbv_lernwelt.api.directory import list_entities
from vbv_lernwelt.api.user import get_cockpit_type, me_user_view from vbv_lernwelt.api.user import get_cockpit_type, get_profile, me_user_view
from vbv_lernwelt.assignment.views import request_assignment_completion_status 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.middleware.auth import django_view_authentication_exempt
from vbv_lernwelt.core.schema import schema from vbv_lernwelt.core.schema import schema
@ -100,6 +100,7 @@ urlpatterns = [
path("sso/", include("vbv_lernwelt.sso.urls")), path("sso/", include("vbv_lernwelt.sso.urls")),
re_path(r'api/core/me/$', me_user_view, name='me_user_view'), re_path(r'api/core/me/$', me_user_view, name='me_user_view'),
re_path(r'api/core/entities/$', list_entities, name='list_entities'), re_path(r'api/core/entities/$', list_entities, name='list_entities'),
path(r'api/core/profile/<signed_int:course_session_id>/<uuid:user_id>', get_profile, name='get_profile_view'),
re_path(r'api/core/login/$', django_view_authentication_exempt(vue_login), re_path(r'api/core/login/$', django_view_authentication_exempt(vue_login),
name='vue_login'), name='vue_login'),

View File

@ -5,9 +5,9 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response from rest_framework.response import Response
from vbv_lernwelt.core.serializers import UserSerializer from vbv_lernwelt.core.serializers import UserSerializer
from vbv_lernwelt.course.models import Course, CourseSessionUser from vbv_lernwelt.course.models import Course, CourseSessionUser
from vbv_lernwelt.course_session_group.models import CourseSessionGroup 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.learning_mentor.models import LearningMentor
@ -59,3 +59,16 @@ def get_cockpit_type(request, course_id: int):
cockpit_type = None cockpit_type = None
return Response({"type": cockpit_type}) 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)

View File

@ -36,17 +36,24 @@ def is_course_session_expert(user, course_session_id: int):
if user.is_superuser: if user.is_superuser:
return True return True
course_session = CourseSession.objects.get(id=course_session_id)
is_supervisor = CourseSessionGroup.objects.filter( is_supervisor = CourseSessionGroup.objects.filter(
supervisor=user, course_session__id=course_session_id supervisor=user, course_session=course_session
).exists() ).exists()
is_expert = CourseSessionUser.objects.filter( is_expert = CourseSessionUser.objects.filter(
course_session_id=course_session_id, course_session=course_session,
user=user, user=user,
role=CourseSessionUser.Role.EXPERT, role=CourseSessionUser.Role.EXPERT,
).exists() ).exists()
return is_supervisor or is_expert is_learning_mentor = LearningMentor.objects.filter(
mentor=user,
course=course_session.course,
).exists()
return is_supervisor or is_expert or is_learning_mentor
def is_course_session_member(user, course_session_id: int | None = None): def is_course_session_member(user, course_session_id: int | None = None):
@ -150,3 +157,16 @@ def has_role_in_course(user: User, course: Course) -> bool:
return True return True
return False 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):
return True
return False

View File

@ -0,0 +1,82 @@
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
from vbv_lernwelt.learning_mentor.models import LearningMentor
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_learning_mentor(self):
# GIVEN
lm = LearningMentor.objects.create(
mentor=self.user,
course=self.course,
)
# WHEN
is_expert = is_course_session_expert(
user=lm.mentor, 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)