Merged develop into feature/VBV-757-clone-course-data-for-completion

This commit is contained in:
Christian Cueni 2024-10-08 14:34:46 +00:00
commit 180958bd42
9 changed files with 304 additions and 16 deletions

View File

@ -8,7 +8,12 @@
</h3> </h3>
<h4 class="grid-in-subtitle">{{ subtitle }}</h4> <h4 class="grid-in-subtitle">{{ subtitle }}</h4>
<div class="flex items-center justify-end gap-x-4 grid-in-icons"> <div class="flex items-center justify-end gap-x-4 grid-in-icons">
<a v-if="canDelete" class="flex cursor-pointer" @click="emit('delete')"> <a
v-if="canDelete"
class="flex cursor-pointer"
data-cy="document-delete-button"
@click="emit('delete')"
>
<it-icon-delete class="h-8 w-8" /> <it-icon-delete class="h-8 w-8" />
</a> </a>

View File

@ -2,7 +2,11 @@
import DocumentListItem from "@/components/circle/DocumentListItem.vue"; import DocumentListItem from "@/components/circle/DocumentListItem.vue";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue"; import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import ItModal from "@/components/ui/ItModal.vue"; import ItModal from "@/components/ui/ItModal.vue";
import { useCourseData, useCurrentCourseSession } from "@/composables"; import {
useCourseData,
useCourseSessionDetailQuery,
useCurrentCourseSession,
} from "@/composables";
import { useExpertCockpitPageData } from "@/pages/cockpit/cockpitPage/composables"; import { useExpertCockpitPageData } from "@/pages/cockpit/cockpitPage/composables";
import DocumentUploadForm from "@/pages/cockpit/documentPage/DocumentUploadForm.vue"; import DocumentUploadForm from "@/pages/cockpit/documentPage/DocumentUploadForm.vue";
import { import {
@ -12,6 +16,7 @@ import {
} from "@/services/files"; } from "@/services/files";
import { useCourseSessionsStore } from "@/stores/courseSessions"; import { useCourseSessionsStore } from "@/stores/courseSessions";
import { useExpertCockpitStore } from "@/stores/expertCockpit"; import { useExpertCockpitStore } from "@/stores/expertCockpit";
import { useUserStore } from "@/stores/user";
import type { CircleDocument, DocumentUploadData } from "@/types"; import type { CircleDocument, DocumentUploadData } from "@/types";
import dialog from "@/utils/confirm-dialog"; import dialog from "@/utils/confirm-dialog";
import { useTranslation } from "i18next-vue"; import { useTranslation } from "i18next-vue";
@ -21,6 +26,9 @@ import { computed, onMounted, ref, watch } from "vue";
const cockpitStore = useExpertCockpitStore(); const cockpitStore = useExpertCockpitStore();
const courseSession = useCurrentCourseSession(); const courseSession = useCurrentCourseSession();
const courseSessionsStore = useCourseSessionsStore(); const courseSessionsStore = useCourseSessionsStore();
const courseSessionDetailResult = useCourseSessionDetailQuery();
const userStore = useUserStore();
const courseData = useCourseData(courseSession.value?.course.slug); const courseData = useCourseData(courseSession.value?.course.slug);
const { t } = useTranslation(); const { t } = useTranslation();
@ -71,6 +79,19 @@ const circleDocuments = computed(() => {
); );
}); });
const canEditDocuments = computed(() => {
const circleExperts = courseSessionDetailResult.filterCircleExperts(
cockpitStore.currentCircle?.slug || ""
);
// hack-ish way to check if the user is an expert for the circle
// supervistors are not allowd to edit documents if they are not experts in the circle
return circleExperts.some(
(expert) =>
expert.user_id === userStore.id &&
expert.id.indexOf("as-ephemeral-supervisor") === -1
);
});
const deleteDocument = async (doc: CircleDocument) => { const deleteDocument = async (doc: CircleDocument) => {
const options = { const options = {
title: t("circlePage.documents.deleteModalTitle"), title: t("circlePage.documents.deleteModalTitle"),
@ -134,18 +155,22 @@ async function uploadDocument(data: DocumentUploadData) {
@update:model-value="cockpitStore.setCurrentCourseCircleFromEvent" @update:model-value="cockpitStore.setCurrentCourseCircleFromEvent"
></ItDropdownSelect> ></ItDropdownSelect>
</div> </div>
<div class="bg-white p-6"> <div class="bg-white p-6">
<button class="btn-primary text-xl" @click="showUploadModal = true"> <button
v-if="canEditDocuments"
class="btn-primary mb-6 text-xl"
data-cy="document-upload-button"
@click="showUploadModal = true"
>
{{ t("circlePage.documents.action") }} {{ t("circlePage.documents.action") }}
</button> </button>
<ul v-if="circleDocuments.length" class="mt-8 border-t border-t-gray-500"> <ul v-if="circleDocuments.length" class="border-t border-t-gray-500">
<DocumentListItem <DocumentListItem
v-for="doc of circleDocuments" v-for="doc of circleDocuments"
:key="doc.url" :key="doc.url"
:subtitle="doc.learning_sequence.title" :subtitle="doc.learning_sequence.title"
:can-delete="true" :can-delete="canEditDocuments"
:doc="doc" :doc="doc"
@delete="deleteDocument(doc)" @delete="deleteDocument(doc)"
/> />

View File

@ -8,7 +8,11 @@
{{ $t("circlePage.documents.userDescription") }} {{ $t("circlePage.documents.userDescription") }}
</div> </div>
</div> </div>
<ul v-if="circleDocuments.length" class="mt-8 border-t border-t-gray-500"> <ul
v-if="circleDocuments.length"
class="mt-8 border-t border-t-gray-500"
data-cy="circle-page-documents"
>
<DocumentListItem <DocumentListItem
v-for="doc of circleDocuments" v-for="doc of circleDocuments"
:key="doc.url" :key="doc.url"

View File

@ -0,0 +1,41 @@
import { login } from "../helpers";
describe("cockpitDocuments.cy.js", () => {
beforeEach(() => {
cy.manageCommand("cypress_reset");
});
describe("Cockpit Document list", () => {
it("Trainer sees document mutation buttons", () => {
login("test-trainer1@example.com", "test");
cy.visit("/course/test-lehrgang/cockpit/documents");
cy.get('[data-cy="document-upload-button"]').should("exist");
cy.get('[data-cy="document-delete-button"]').should("exist");
});
it("Supervisor does not see document mutation buttons", () => {
login("test-supervisor1@example.com", "test");
cy.visit("/course/test-lehrgang/cockpit/documents");
cy.get('[data-cy="document-upload-button"]').should("not.exist");
cy.get('[data-cy="document-delete-button"]').should("not.exist");
});
});
describe("Preview", () => {
it("Supervisor sees documents list", () => {
login("test-supervisor1@example.com", "test");
cy.visit("/course/test-lehrgang/learn/fahrzeug");
cy.get('[data-cy="circle-page-documents"]').should("exist");
});
it("Berufsbildner sees document list", () => {
login("test-berufsbildner1@example.com", "test");
cy.visit("/course/test-lehrgang/learn/fahrzeug");
cy.get('[data-cy="circle-page-documents"]').should("exist");
});
});
});

View File

@ -2,6 +2,7 @@ from collections import deque
from datetime import datetime from datetime import datetime
from dateutil.relativedelta import MO, TH, TU, WE, relativedelta from dateutil.relativedelta import MO, TH, TU, WE, relativedelta
from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils import timezone from django.utils import timezone
from slugify import slugify from slugify import slugify
from wagtail.rich_text import RichText from wagtail.rich_text import RichText
@ -48,6 +49,7 @@ from vbv_lernwelt.core.utils import safe_deque_popleft
from vbv_lernwelt.course.consts import COURSE_TEST_ID from vbv_lernwelt.course.consts import COURSE_TEST_ID
from vbv_lernwelt.course.factories import CoursePageFactory from vbv_lernwelt.course.factories import CoursePageFactory
from vbv_lernwelt.course.models import ( from vbv_lernwelt.course.models import (
CircleDocument,
Course, Course,
CourseCategory, CourseCategory,
CourseConfiguration, CourseConfiguration,
@ -63,12 +65,14 @@ from vbv_lernwelt.course_session.models import (
) )
from vbv_lernwelt.course_session_group.models import CourseSessionGroup from vbv_lernwelt.course_session_group.models import CourseSessionGroup
from vbv_lernwelt.feedback.services import update_feedback_response from vbv_lernwelt.feedback.services import update_feedback_response
from vbv_lernwelt.files.models import UploadFile
from vbv_lernwelt.learning_mentor.models import AgentParticipantRelation from vbv_lernwelt.learning_mentor.models import AgentParticipantRelation
from vbv_lernwelt.learnpath.models import ( from vbv_lernwelt.learnpath.models import (
Circle, Circle,
LearningContentAssignment, LearningContentAssignment,
LearningContentAttendanceCourse, LearningContentAttendanceCourse,
LearningContentEdoniqTest, LearningContentEdoniqTest,
LearningSequence,
) )
from vbv_lernwelt.learnpath.tests.learning_path_factories import ( from vbv_lernwelt.learnpath.tests.learning_path_factories import (
CircleFactory, CircleFactory,
@ -187,6 +191,27 @@ def create_test_course(
) )
csac.due_date.save() csac.due_date.save()
# create fake doc (will be uploaded to aws)
ls = LearningSequence.objects.get(
title="Vorbereitung",
slug="test-lehrgang-lp-circle-fahrzeug-ls-vorbereitung",
)
fake_file = SimpleUploadedFile(
"fake_file.txt", b"file_content_here", content_type="text/plain"
)
pseudo_file = UploadFile.objects.create(
file=fake_file,
original_file_name="Test Dokument",
file_name="Test Dokument",
file_type="txt",
)
CircleDocument.objects.create(
file=pseudo_file,
name="Test Dokument)",
course_session=cs_bern,
learning_sequence=ls,
)
if include_vv: if include_vv:
csac = CourseSessionAttendanceCourse.objects.create( csac = CourseSessionAttendanceCourse.objects.create(
course_session=cs_bern, course_session=cs_bern,

View File

@ -86,16 +86,10 @@ class CourseSessionSerializer(serializers.ModelSerializer):
course = serializers.SerializerMethodField() course = serializers.SerializerMethodField()
actions = serializers.SerializerMethodField() actions = serializers.SerializerMethodField()
user_roles = serializers.SerializerMethodField()
def get_course(self, obj): def get_course(self, obj):
return CourseSerializer(obj.course).data return CourseSerializer(obj.course).data
def get_user_roles(self, obj):
if hasattr(obj, "roles"):
return list(obj.roles)
return []
class Meta: class Meta:
model = CourseSession model = CourseSession
fields = [ fields = [
@ -107,7 +101,6 @@ class CourseSessionSerializer(serializers.ModelSerializer):
"start_date", "start_date",
"end_date", "end_date",
"actions", "actions",
"user_roles",
] ]
read_only_fields = ["actions"] read_only_fields = ["actions"]

View File

@ -4,12 +4,14 @@ from rest_framework.response import Response
from vbv_lernwelt.course.models import CircleDocument from vbv_lernwelt.course.models import CircleDocument
from vbv_lernwelt.course.serializers import CircleDocumentSerializer from vbv_lernwelt.course.serializers import CircleDocumentSerializer
from vbv_lernwelt.iam.permissions import has_course_session_access from vbv_lernwelt.iam.permissions import (
has_course_session_document_access,
)
@api_view(["GET"]) @api_view(["GET"])
def get_course_session_documents(request, course_session_id): def get_course_session_documents(request, course_session_id):
if not has_course_session_access(request.user, course_session_id): if not has_course_session_document_access(request.user, course_session_id):
raise PermissionDenied() raise PermissionDenied()
circle_documents = CircleDocument.objects.filter( circle_documents = CircleDocument.objects.filter(

View File

@ -44,6 +44,21 @@ def has_course_session_access(user, course_session_id: int):
).exists() ).exists()
def has_course_session_document_access(user, course_session_id: int):
if user.is_superuser:
return True
return (
CourseSessionUser.objects.filter(
course_session_id=course_session_id, user=user
).exists()
or is_course_session_berufsbildner(user, course_session_id)
or CourseSessionGroup.objects.filter(
course_session=course_session_id, supervisor=user.id
).exists()
)
def has_course_session_preview(user, course_session_id: int): def has_course_session_preview(user, course_session_id: int):
if user.is_superuser: if user.is_superuser:
return True return True

View File

@ -0,0 +1,178 @@
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 (
has_course_session_document_access,
)
from vbv_lernwelt.learning_mentor.models import (
AgentParticipantRelation,
AgentParticipantRoleType,
)
class PermissionsTestCase(TestCase):
def setUp(self):
self.course, _ = create_course("Test Course")
self.course_session = create_course_session(
course=self.course, title="Test Session"
)
self.other_course_session = create_course_session(
course=self.course, title="Other Session"
)
self.user = create_user("user")
def test_regionenleiter_has_course_session_document_access(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
has_access = has_course_session_document_access(
self.user, self.course_session.id
)
some = CourseSessionGroup.objects.filter(
course_session=self.course_session.id, supervisor=self.user.id
)
print(some)
# THEN
self.assertTrue(has_access)
def test_regionenleiter_has_no_course_session_document_access(self):
# GIVEN
csg = CourseSessionGroup.objects.create(name="Test Group", course=self.course)
csg.course_session.add(self.other_course_session)
csg.supervisor.add(self.user)
# WHEN
has_access = has_course_session_document_access(
self.user, self.course_session.id
)
some = CourseSessionGroup.objects.filter(
course_session=self.course_session.id, supervisor=self.user.id
)
print(some)
# THEN
self.assertFalse(has_access)
def test_expert_has_course_session_document_access(self):
# GIVEN
_csu = CourseSessionUser.objects.create(
course_session=self.course_session,
user=self.user,
role=CourseSessionUser.Role.EXPERT,
)
# WHEN
has_access = has_course_session_document_access(
self.user, self.course_session.id
)
# THEN
self.assertTrue(has_access)
def test_expert_has_no_course_session_document_access(self):
# GIVEN
_csu = CourseSessionUser.objects.create(
course_session=self.course_session,
user=self.user,
role=CourseSessionUser.Role.EXPERT,
)
# WHEN
has_access = has_course_session_document_access(
self.user, self.other_course_session.id
)
# THEN
self.assertFalse(has_access)
def test_member_has_course_session_document_access(self):
# GIVEN
_csu = CourseSessionUser.objects.create(
course_session=self.course_session,
user=self.user,
role=CourseSessionUser.Role.MEMBER,
)
# WHEN
has_access = has_course_session_document_access(
self.user, self.course_session.id
)
# THEN
self.assertTrue(has_access)
def test_member_has_no_course_session_document_access(self):
# GIVEN
_csu = CourseSessionUser.objects.create(
course_session=self.course_session,
user=self.user,
role=CourseSessionUser.Role.MEMBER,
)
# WHEN
has_access = has_course_session_document_access(
self.user, self.other_course_session.id
)
# THEN
self.assertFalse(has_access)
def test_berufsbildner_has_course_session_document_access(self):
# GIVEN
member = create_user("member")
_csu = CourseSessionUser.objects.create(
course_session=self.course_session,
user=member,
role=CourseSessionUser.Role.MEMBER,
)
AgentParticipantRelation.objects.create(
agent=self.user,
participant=_csu,
role=AgentParticipantRoleType.BERUFSBILDNER.value,
)
# WHEN
has_access = has_course_session_document_access(
self.user, self.course_session.id
)
# THEN
self.assertTrue(has_access)
def test_berufsbildner_has_no_course_session_document_access(self):
# GIVEN
member = create_user("member")
_csu = CourseSessionUser.objects.create(
course_session=self.other_course_session,
user=member,
role=CourseSessionUser.Role.MEMBER,
)
AgentParticipantRelation.objects.create(
agent=self.user,
participant=_csu,
role=AgentParticipantRoleType.BERUFSBILDNER.value,
)
# WHEN
has_access = has_course_session_document_access(
self.user, self.course_session.id
)
# THEN
self.assertFalse(has_access)