feat: cockpit user profile
This commit is contained in:
parent
a154341fae
commit
6a985ce607
|
|
@ -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>
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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>
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
|
|
@ -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'),
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
Loading…
Reference in New Issue