Add assigment task file upload

This commit is contained in:
Reto Aebersold 2023-10-04 14:14:56 +02:00
parent ea8ab0adcb
commit d4cb978de3
22 changed files with 330 additions and 61 deletions

View File

@ -14,10 +14,10 @@ import type { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-
*/
const documents = {
"\n mutation AttendanceCheckMutation(\n $attendanceCourseId: ID!\n $attendanceUserList: [AttendanceUserInputType]!\n ) {\n update_course_session_attendance_course_users(\n id: $attendanceCourseId\n attendance_user_list: $attendanceUserList\n ) {\n course_session_attendance_course {\n id\n attendance_user_list {\n user_id\n first_name\n last_name\n email\n status\n }\n }\n }\n }\n": types.AttendanceCheckMutationDocument,
"\n mutation UpsertAssignmentCompletion(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n $completionStatus: AssignmentCompletionStatus!\n $completionDataString: String!\n $evaluationPoints: Float\n $initializeCompletion: Boolean\n ) {\n upsert_assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n learning_content_page_id: $learningContentId\n assignment_user_id: $assignmentUserId\n completion_status: $completionStatus\n completion_data_string: $completionDataString\n evaluation_points: $evaluationPoints\n initialize_completion: $initializeCompletion\n ) {\n assignment_completion {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_points\n completion_data\n }\n }\n }\n": types.UpsertAssignmentCompletionDocument,
"\n mutation UpsertAssignmentCompletion(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n $completionStatus: AssignmentCompletionStatus!\n $completionDataString: String!\n $evaluationPoints: Float\n $initializeCompletion: Boolean\n ) {\n upsert_assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n learning_content_page_id: $learningContentId\n assignment_user_id: $assignmentUserId\n completion_status: $completionStatus\n completion_data_string: $completionDataString\n evaluation_points: $evaluationPoints\n initialize_completion: $initializeCompletion\n ) {\n assignment_completion {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_points\n completion_data\n task_completion_data\n }\n }\n }\n": types.UpsertAssignmentCompletionDocument,
"\n fragment CoursePageFields on CoursePageInterface {\n title\n id\n slug\n content_type\n frontend_url\n }\n": types.CoursePageFieldsFragmentDoc,
"\n query attendanceCheckQuery($courseSessionId: ID!) {\n course_session_attendance_course(id: $courseSessionId) {\n id\n attendance_user_list {\n user_id\n status\n }\n }\n }\n": types.AttendanceCheckQueryDocument,
"\n query assignmentCompletionQuery(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n ) {\n assignment(id: $assignmentId) {\n assignment_type\n needs_expert_evaluation\n max_points\n content_type\n effort_required\n evaluation_description\n evaluation_document_url\n evaluation_tasks\n id\n intro_text\n performance_objectives\n slug\n tasks\n title\n translation_key\n competence_certificate {\n ...CoursePageFields\n }\n }\n assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n assignment_user_id: $assignmentUserId\n learning_content_page_id: $learningContentId\n ) {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_user {\n id\n }\n assignment_user {\n id\n }\n evaluation_points\n evaluation_max_points\n evaluation_passed\n edoniq_extended_time_flag\n completion_data\n }\n }\n": types.AssignmentCompletionQueryDocument,
"\n query assignmentCompletionQuery(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n ) {\n assignment(id: $assignmentId) {\n assignment_type\n needs_expert_evaluation\n max_points\n content_type\n effort_required\n evaluation_description\n evaluation_document_url\n evaluation_tasks\n id\n intro_text\n performance_objectives\n slug\n tasks\n title\n translation_key\n competence_certificate {\n ...CoursePageFields\n }\n }\n assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n assignment_user_id: $assignmentUserId\n learning_content_page_id: $learningContentId\n ) {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_user {\n id\n }\n assignment_user {\n id\n }\n evaluation_points\n evaluation_max_points\n evaluation_passed\n edoniq_extended_time_flag\n completion_data\n task_completion_data\n }\n }\n": types.AssignmentCompletionQueryDocument,
"\n query courseQuery($courseId: ID!) {\n course(id: $courseId) {\n id\n slug\n title\n category_name\n learning_path {\n id\n }\n }\n }\n": types.CourseQueryDocument,
"\n query competenceCertificateQuery($courseSlug: String!, $courseSessionId: ID!) {\n competence_certificate_list(course_slug: $courseSlug) {\n ...CoursePageFields\n competence_certificates {\n ...CoursePageFields\n assignments {\n ...CoursePageFields\n assignment_type\n max_points\n completion(course_session_id: $courseSessionId) {\n id\n completion_status\n submitted_at\n evaluation_points\n evaluation_max_points\n evaluation_passed\n }\n learning_content {\n title\n id\n slug\n content_type\n frontend_url\n circle {\n ...CoursePageFields\n }\n }\n }\n }\n }\n }\n": types.CompetenceCertificateQueryDocument,
"\n query courseSessionDetail($courseSessionId: ID!) {\n course_session(id: $courseSessionId) {\n id\n title\n course {\n id\n title\n slug\n }\n users {\n id\n user_id\n first_name\n last_name\n email\n avatar_url\n role\n circles {\n id\n title\n slug\n }\n }\n attendance_courses {\n id\n location\n trainer\n due_date {\n id\n start\n end\n }\n learning_content_id\n learning_content {\n id\n title\n circle {\n id\n title\n slug\n }\n }\n }\n assignments {\n id\n submission_deadline {\n id\n start\n }\n evaluation_deadline {\n id\n start\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n edoniq_tests {\n id\n deadline {\n id\n start\n end\n }\n learning_content {\n id\n title\n content_assignment {\n id\n title\n assignment_type\n }\n }\n }\n }\n }\n": types.CourseSessionDetailDocument,
@ -45,7 +45,7 @@ export function graphql(source: "\n mutation AttendanceCheckMutation(\n $att
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n mutation UpsertAssignmentCompletion(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n $completionStatus: AssignmentCompletionStatus!\n $completionDataString: String!\n $evaluationPoints: Float\n $initializeCompletion: Boolean\n ) {\n upsert_assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n learning_content_page_id: $learningContentId\n assignment_user_id: $assignmentUserId\n completion_status: $completionStatus\n completion_data_string: $completionDataString\n evaluation_points: $evaluationPoints\n initialize_completion: $initializeCompletion\n ) {\n assignment_completion {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_points\n completion_data\n }\n }\n }\n"): (typeof documents)["\n mutation UpsertAssignmentCompletion(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n $completionStatus: AssignmentCompletionStatus!\n $completionDataString: String!\n $evaluationPoints: Float\n $initializeCompletion: Boolean\n ) {\n upsert_assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n learning_content_page_id: $learningContentId\n assignment_user_id: $assignmentUserId\n completion_status: $completionStatus\n completion_data_string: $completionDataString\n evaluation_points: $evaluationPoints\n initialize_completion: $initializeCompletion\n ) {\n assignment_completion {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_points\n completion_data\n }\n }\n }\n"];
export function graphql(source: "\n mutation UpsertAssignmentCompletion(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n $completionStatus: AssignmentCompletionStatus!\n $completionDataString: String!\n $evaluationPoints: Float\n $initializeCompletion: Boolean\n ) {\n upsert_assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n learning_content_page_id: $learningContentId\n assignment_user_id: $assignmentUserId\n completion_status: $completionStatus\n completion_data_string: $completionDataString\n evaluation_points: $evaluationPoints\n initialize_completion: $initializeCompletion\n ) {\n assignment_completion {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_points\n completion_data\n task_completion_data\n }\n }\n }\n"): (typeof documents)["\n mutation UpsertAssignmentCompletion(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n $completionStatus: AssignmentCompletionStatus!\n $completionDataString: String!\n $evaluationPoints: Float\n $initializeCompletion: Boolean\n ) {\n upsert_assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n learning_content_page_id: $learningContentId\n assignment_user_id: $assignmentUserId\n completion_status: $completionStatus\n completion_data_string: $completionDataString\n evaluation_points: $evaluationPoints\n initialize_completion: $initializeCompletion\n ) {\n assignment_completion {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_points\n completion_data\n task_completion_data\n }\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
@ -57,7 +57,7 @@ export function graphql(source: "\n query attendanceCheckQuery($courseSessionId
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/
export function graphql(source: "\n query assignmentCompletionQuery(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n ) {\n assignment(id: $assignmentId) {\n assignment_type\n needs_expert_evaluation\n max_points\n content_type\n effort_required\n evaluation_description\n evaluation_document_url\n evaluation_tasks\n id\n intro_text\n performance_objectives\n slug\n tasks\n title\n translation_key\n competence_certificate {\n ...CoursePageFields\n }\n }\n assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n assignment_user_id: $assignmentUserId\n learning_content_page_id: $learningContentId\n ) {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_user {\n id\n }\n assignment_user {\n id\n }\n evaluation_points\n evaluation_max_points\n evaluation_passed\n edoniq_extended_time_flag\n completion_data\n }\n }\n"): (typeof documents)["\n query assignmentCompletionQuery(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n ) {\n assignment(id: $assignmentId) {\n assignment_type\n needs_expert_evaluation\n max_points\n content_type\n effort_required\n evaluation_description\n evaluation_document_url\n evaluation_tasks\n id\n intro_text\n performance_objectives\n slug\n tasks\n title\n translation_key\n competence_certificate {\n ...CoursePageFields\n }\n }\n assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n assignment_user_id: $assignmentUserId\n learning_content_page_id: $learningContentId\n ) {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_user {\n id\n }\n assignment_user {\n id\n }\n evaluation_points\n evaluation_max_points\n evaluation_passed\n edoniq_extended_time_flag\n completion_data\n }\n }\n"];
export function graphql(source: "\n query assignmentCompletionQuery(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n ) {\n assignment(id: $assignmentId) {\n assignment_type\n needs_expert_evaluation\n max_points\n content_type\n effort_required\n evaluation_description\n evaluation_document_url\n evaluation_tasks\n id\n intro_text\n performance_objectives\n slug\n tasks\n title\n translation_key\n competence_certificate {\n ...CoursePageFields\n }\n }\n assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n assignment_user_id: $assignmentUserId\n learning_content_page_id: $learningContentId\n ) {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_user {\n id\n }\n assignment_user {\n id\n }\n evaluation_points\n evaluation_max_points\n evaluation_passed\n edoniq_extended_time_flag\n completion_data\n task_completion_data\n }\n }\n"): (typeof documents)["\n query assignmentCompletionQuery(\n $assignmentId: ID!\n $courseSessionId: ID!\n $learningContentId: ID\n $assignmentUserId: UUID\n ) {\n assignment(id: $assignmentId) {\n assignment_type\n needs_expert_evaluation\n max_points\n content_type\n effort_required\n evaluation_description\n evaluation_document_url\n evaluation_tasks\n id\n intro_text\n performance_objectives\n slug\n tasks\n title\n translation_key\n competence_certificate {\n ...CoursePageFields\n }\n }\n assignment_completion(\n assignment_id: $assignmentId\n course_session_id: $courseSessionId\n assignment_user_id: $assignmentUserId\n learning_content_page_id: $learningContentId\n ) {\n id\n completion_status\n submitted_at\n evaluation_submitted_at\n evaluation_user {\n id\n }\n assignment_user {\n id\n }\n evaluation_points\n evaluation_max_points\n evaluation_passed\n edoniq_extended_time_flag\n completion_data\n task_completion_data\n }\n }\n"];
/**
* The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients.
*/

File diff suppressed because one or more lines are too long

View File

@ -312,6 +312,7 @@ type AssignmentCompletionObjectType {
completion_status: AssignmentAssignmentCompletionCompletionStatusChoices!
completion_data: GenericScalar
additional_json_data: JSONString!
task_completion_data: GenericScalar
learning_content_page_id: ID
}

View File

@ -51,6 +51,7 @@ export const UPSERT_ASSIGNMENT_COMPLETION_MUTATION = graphql(`
evaluation_submitted_at
evaluation_points
completion_data
task_completion_data
}
}
}

View File

@ -71,6 +71,7 @@ export const ASSIGNMENT_COMPLETION_QUERY = graphql(`
evaluation_passed
edoniq_extended_time_flag
completion_data
task_completion_data
}
}
`);

View File

@ -8,12 +8,14 @@ import type {
AssignmentCompletionData,
AssignmentTask,
UserDataConfirmation,
UserDataFile,
UserDataText,
} from "@/types";
import { useMutation } from "@urql/vue";
import { useDebounceFn } from "@vueuse/core";
import log from "loglevel";
import { computed, reactive } from "vue";
import AttachmentSection from "@/pages/learningPath/learningContentPage/assignment/AttachmentSection.vue";
const props = defineProps<{
assignmentId: string;
@ -77,6 +79,16 @@ const onUpdateConfirmation = (id: string, value: boolean) => {
upsertAssignmentCompletion(data);
};
const onUpdateFile = (taskId: string, value: string | null) => {
const data: AssignmentCompletionData = {};
data[taskId] = {
user_data: {
fileId: value,
} as UserDataFile,
};
upsertAssignmentCompletion(data);
};
const getBlockData = (id: string) => {
const userData = getCompletionDataForUserInput(id)?.user_data;
if (userData && "text" in userData) {
@ -95,6 +107,15 @@ const onToggleCheckbox = (id: string) => {
const completionStatus = computed(() => {
return props.assignmentCompletion?.completion_status ?? "IN_PROGRESS";
});
const taskFileInfo = computed(() => {
if (!props.assignmentCompletion) {
return null;
}
const taskUserData =
props.assignmentCompletion.task_completion_data[props.task.id]?.user_data;
return taskUserData?.fileInfo ?? null;
});
</script>
<template>
@ -133,12 +154,11 @@ const completionStatus = computed(() => {
</div>
</div>
</div>
<div v-if="props.task.value.file_submission_required">
<p class="text-large">Datei hochladen</p>
<p class="text-sm text-gray-900">
Mögliche Formate: .JPG, .PNG, .PDF, .DOC, .MOV, .PPT
</p>
</div>
<AttachmentSection
v-if="props.task.value.file_submission_required"
:file-info="taskFileInfo"
class="mt-8"
@file-uploaded="onUpdateFile(props.task.id, $event)"
@file-deleted="onUpdateFile(props.task.id, null)"
></AttachmentSection>
</template>

View File

@ -0,0 +1,85 @@
<script setup lang="ts">
import { ref } from "vue";
import { presignUpload, uploadFile } from "@/services/files";
import type { UserDataFileInfo } from "@/types";
const props = defineProps<{
fileInfo: UserDataFileInfo | null;
}>();
const emit = defineEmits(["fileUploaded", "fileDeleted"]);
const selectedFile = ref(props.fileInfo);
const loading = ref(false);
const uploadError = ref(false);
async function fileSelected(e: Event) {
const { files } = e.target as HTMLInputElement;
if (!files?.length) return;
try {
uploadError.value = false;
loading.value = true;
const file = files[0];
const presignData = await presignUpload(file);
await uploadFile(presignData.pre_sign, file);
selectedFile.value = presignData.file_info;
emit("fileUploaded", presignData.file_info.id);
} catch (error) {
console.error(error);
uploadError.value = true;
} finally {
loading.value = false;
}
}
function handleDelete() {
selectedFile.value = null;
emit("fileDeleted");
}
</script>
<template>
<div>
<h4 class="mb-2 text-xl">{{ $t("a.Datei hochladen") }}</h4>
<template v-if="loading">
{{ $t("a.Laden...") }}
</template>
<template v-else>
<template v-if="!selectedFile">
<div class="relative mb-4 flex cursor-pointer items-center text-blue-800">
<input
id="upload"
type="file"
class="absolute opacity-0"
accept=".pdf,.jpg,.jpeg,.png,.doc,.docx,.mov,.ppt,.pptx"
@change="fileSelected"
/>
<it-icon-document class="mr-1.5 h-7 w-7 text-blue-800" />
{{ $t("a.Datei auswählen") }}
</div>
<p class="text-sm text-gray-900">
{{ $t("a.Mögliche Formate") }}: .JPG, .PNG, .PDF, .DOC, .MOV, .PPT
</p>
</template>
<template v-else>
<div class="flex items-center gap-x-2">
<h4 class="mr-4 text-lg font-bold">{{ selectedFile.name }}</h4>
<a class="flex cursor-pointer" @click="handleDelete">
<it-icon-delete class="h-8 w-8" />
</a>
<a :href="selectedFile.url" class="flex">
<it-icon-download class="h-8 w-8" />
</a>
</div>
</template>
<p v-if="uploadError" class="mt-3 text-red-700">
{{ $t("a.Datei kann nicht gespeichert werden.") }}
</p>
</template>
</div>
</template>

View File

@ -20,7 +20,7 @@ async function startFileUpload(fileData: DocumentUploadData, courseSessionId: st
});
}
function uploadFile(fileData: FileData, file: File) {
export async function uploadFile(fileData: FileData, file: File) {
if (fileData.fields) {
return s3Upload(fileData, file);
} else {
@ -44,7 +44,7 @@ function directUpload(fileData: FileData, file: File) {
// @ts-ignore
options.headers["X-CSRFToken"] = getCookieValue("csrftoken");
handleUpload(fileData.url, options);
return handleUpload(fileData.url, options);
}
function s3Upload(fileData: FileData, file: File) {
@ -63,12 +63,13 @@ function s3Upload(fileData: FileData, file: File) {
return handleUpload(fileData.url, options);
}
function handleUpload(url: string, options: RequestInit) {
return itFetch(url, options).then((response) => {
return response.json().catch(() => {
return Promise.resolve(null);
});
});
async function handleUpload(url: string, options: RequestInit) {
const response = await itFetch(url, options);
try {
return await response.json();
} catch (e) {
return Promise.resolve(null);
}
}
export async function uploadCircleDocument(
@ -105,3 +106,10 @@ export async function deleteCircleDocument(documentId: string, bustCacheUrlKey =
export async function fetchCourseSessionDocuments(courseSessionId: string) {
return itGetCached(`/api/core/document/list/${courseSessionId}/`);
}
export async function presignUpload(file: File) {
return await itPost(`/api/core/storage/presign/`, {
file_type: file.type,
file_name: file.name,
});
}

View File

@ -395,7 +395,9 @@ export interface PerformanceCriteria extends BaseCourseWagtailPage {
readonly competence_id: string;
readonly circle: CircleLight;
readonly course_category: CourseCategory;
readonly learning_unit: BaseCourseWagtailPage & { evaluate_url: string };
readonly learning_unit: BaseCourseWagtailPage & {
evaluate_url: string;
};
}
export interface CompetencePage extends BaseCourseWagtailPage {
@ -602,6 +604,17 @@ export interface UserDataConfirmation {
confirmation: boolean;
}
export interface UserDataFile {
fileId?: string;
fileInfo?: UserDataFileInfo;
}
export interface UserDataFileInfo {
id: string;
name: string;
url: string;
}
export interface ExpertData {
points?: number;
text?: string;
@ -613,11 +626,21 @@ export interface AssignmentCompletionData {
// "<user_confirmation:uuid>": {"user_data": {"confirmation": true}},
// }
[key: string]: {
user_data?: UserDataText | UserDataConfirmation;
user_data?: UserDataText | UserDataConfirmation | UserDataFile;
expert_data?: ExpertData;
};
}
export interface AssignmentTaskCompletionData {
// {
// "<user_text_input:uuid>": {"user_data": {"text": "some text from user"}},
// "<user_confirmation:uuid>": {"user_data": {"confirmation": true}},
// }
[key: string]: {
user_data?: UserDataFile;
};
}
export interface AssignmentCompletion {
id: string;
created_at: string;
@ -630,6 +653,7 @@ export interface AssignmentCompletion {
completion_status: AssignmentCompletionStatus;
evaluation_user: string | null;
completion_data: AssignmentCompletionData;
task_completion_data: AssignmentTaskCompletionData;
edoniq_extended_time_flag: boolean;
evaluation_points: number | null;
evaluation_max_points: number | null;

View File

@ -10,6 +10,9 @@ from django.views import defaults as default_views
from django.views.decorators.csrf import csrf_exempt
from django_ratelimit.exceptions import Ratelimited
from graphene_django.views import GraphQLView
from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls
from vbv_lernwelt.assignment.views import request_assignment_completion_status
from vbv_lernwelt.core.middleware.auth import django_view_authentication_exempt
@ -47,15 +50,13 @@ from vbv_lernwelt.feedback.views import (
get_expert_feedbacks_for_course,
get_feedback_for_circle,
)
from vbv_lernwelt.files.views import presign
from vbv_lernwelt.importer.views import (
coursesessions_students_import,
coursesessions_trainers_import,
t2l_sync,
)
from vbv_lernwelt.notify.views import email_notification_settings
from wagtail import urls as wagtail_urls
from wagtail.admin import urls as wagtailadmin_urls
from wagtail.documents import urls as wagtaildocs_urls
class SignedIntConverter(IntConverter):
@ -143,6 +144,10 @@ urlpatterns = [
get_course_session_documents,
name='get_course_session_documents'),
# file storage
path(r'api/core/storage/presign/', presign,
name='storage_presign'),
# feedback
path(r'api/core/feedback/<str:course_session_id>/summary/',
get_expert_feedbacks_for_course,

View File

@ -25,7 +25,6 @@ django-ratelimit
django-ipware
django-csp
django-storages
django-storages[azure]
django-notifications-hq
django-jsonform
django-constance

View File

@ -33,7 +33,6 @@ azure-identity==1.14.0
azure-storage-blob==12.17.0
# via
# -r requirements.in
# django-storages
bcrypt==4.0.1
# via paramiko
beautifulsoup4==4.11.2
@ -129,7 +128,7 @@ django-ratelimit==4.1.0
# via -r requirements.in
django-redis==5.3.0
# via -r requirements.in
django-storages[azure]==1.13.2
django-storages==1.13.2
# via -r requirements.in
django-taggit==4.0.0
# via wagtail

View File

@ -13,6 +13,7 @@ from vbv_lernwelt.learnpath.graphql.types import LearningContentInterface
class AssignmentCompletionObjectType(DjangoObjectType):
completion_data = GenericScalar()
task_completion_data = GenericScalar()
learning_content_page_id = graphene.ID(source="learning_content_page_id")
class Meta:
@ -35,6 +36,7 @@ class AssignmentCompletionObjectType(DjangoObjectType):
"evaluation_points",
"evaluation_passed",
"evaluation_max_points",
"task_completion_data"
)
@ -72,11 +74,11 @@ class AssignmentObjectType(DjangoObjectType):
return self.find_attached_learning_content()
def resolve_completion(
self,
info,
course_session_id,
learning_content_page_id=None,
assignment_user_id=None,
self,
info,
course_session_id,
learning_content_page_id=None,
assignment_user_id=None,
):
if learning_content_page_id is None:
lp = self.find_attached_learning_content()
@ -92,17 +94,17 @@ class AssignmentObjectType(DjangoObjectType):
def resolve_assignment_completion(
info,
assignment_id,
course_session_id,
learning_content_page_id=None,
assignment_user_id=None,
info,
assignment_id,
course_session_id,
learning_content_page_id=None,
assignment_user_id=None,
):
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(
info.context.user, course_session_id
info.context.user, course_session_id
):
course_id = CourseSession.objects.get(id=course_session_id).course_id
if has_course_access(info.context.user, course_id):

View File

@ -1,3 +1,4 @@
import copy
import uuid
from enum import Enum
@ -16,6 +17,7 @@ from vbv_lernwelt.core.constants import (
from vbv_lernwelt.core.model_utils import find_available_slug
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseBasePage
from vbv_lernwelt.files.models import UploadFile
class AssignmentListPage(CourseBasePage):
@ -302,7 +304,7 @@ class AssignmentCompletionStatus(Enum):
def is_valid_assignment_completion_status(
completion_status: AssignmentCompletionStatus,
completion_status: AssignmentCompletionStatus,
):
return completion_status.value in AssignmentCompletionStatus.__members__
@ -370,6 +372,34 @@ class AssignmentCompletion(models.Model):
"""
return f"{self.course_session.course.get_cockpit_url()}/assignment/{self.assignment.id}/{self.assignment_user.id}"
@property
def task_completion_data(self):
data = {}
for task in self.assignment.tasks:
data[task.id] = get_task_data(task, self.completion_data)
return data
def get_file_info(file_id):
file_info = UploadFile.objects.filter(id=file_id).first()
if file_info:
return {
"id": str(file_info.id),
"name": file_info.original_file_name,
"url": file_info.url,
}
def get_task_data(task, completion_data):
task_data = copy.deepcopy(completion_data.get(task.id, {}))
user_data = task_data.get("user_data", {})
file_id = user_data.get("fileId")
if file_id:
user_data["fileInfo"] = get_file_info(file_id)
return task_data
class AssignmentCompletionAuditLog(models.Model):
"""

View File

@ -17,6 +17,7 @@ class AssignmentCompletionSerializer(serializers.ModelSerializer):
"course_session",
"completion_status",
"completion_data",
"task_completion_data",
"evaluation_user",
"additional_json_data",
"evaluation_points",

View File

@ -108,8 +108,8 @@ def update_assignment_completion(
)
if (
completion_status == AssignmentCompletionStatus.IN_PROGRESS
and ac.completion_status != "IN_PROGRESS"
completion_status == AssignmentCompletionStatus.IN_PROGRESS
and ac.completion_status != "IN_PROGRESS"
):
raise serializers.ValidationError(
{
@ -179,6 +179,14 @@ def update_assignment_completion(
ac.edoniq_extended_time_flag = edoniq_extended_time_flag
ac.additional_json_data = ac.additional_json_data | additional_json_data
task_ids = [task.id for task in assignment.tasks]
for key, value in completion_data.items():
if key in task_ids:
stored_entry = ac.completion_data.get(key, {})
stored_entry.update(value)
ac.completion_data[key] = stored_entry
# TODO: make more validation of the provided input -> maybe with graphql
completion_data = _remove_unknown_entries(assignment, completion_data)
for key, value in completion_data.items():
@ -189,9 +197,9 @@ def update_assignment_completion(
if copy_task_data:
# copy over the question data, so that we don't lose the context
substasks = assignment.get_input_tasks()
sub_tasks = assignment.get_input_tasks()
for key, value in ac.completion_data.items():
task_data = find_first(substasks, pred=lambda x: x["id"] == key)
task_data = find_first(sub_tasks, pred=lambda x: x["id"] == key)
if task_data:
ac.completion_data[key].update(task_data)

View File

@ -1,6 +1,7 @@
import json
from datetime import date
from django.core.files.uploadedfile import SimpleUploadedFile
from django.utils import timezone
from graphene_django.utils import GraphQLTestCase
@ -14,6 +15,7 @@ from vbv_lernwelt.core.models import User
from vbv_lernwelt.core.utils import find_first
from vbv_lernwelt.course.creators.test_course import create_test_course
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.files.models import UploadFile
from vbv_lernwelt.notify.models import Notification
@ -31,6 +33,7 @@ class AttendanceCourseUserMutationTestCase(GraphQLTestCase):
slug="test-lehrgang-assignment-überprüfen-einer-motorfahrzeugs-versicherungspolice"
)
self.assignment_subtasks = self.assignment.filter_user_subtasks()
self.assignment_task_ids = [t.id for t in self.assignment.tasks]
# self.client.force_login(self.trainer)
@ -40,9 +43,27 @@ class AttendanceCourseUserMutationTestCase(GraphQLTestCase):
self.assignment_subtasks, pred=lambda x: x["type"] == "user_text_input"
)
task_id = self.assignment_task_ids[0]
# Create file
uploaded_file = SimpleUploadedFile("file.txt", b"file_content")
file = UploadFile(
original_file_name="file.txt",
file_name="file.txt",
file_type="text/plain",
uploaded_by=self.student,
file=uploaded_file,
)
file.full_clean()
file.save()
file_id = str(file.id)
file_url = file.url
completion_data_string = json.dumps(
{
user_text_input["id"]: {"user_data": {"text": "Hallo via API"}},
task_id: {"user_data": {"fileId": file_id}},
}
).replace('"', '\\"')
@ -58,6 +79,7 @@ class AttendanceCourseUserMutationTestCase(GraphQLTestCase):
id
completion_status
completion_data
task_completion_data
assignment_user {{ id }}
assignment {{ id }}
}}
@ -79,9 +101,25 @@ class AttendanceCourseUserMutationTestCase(GraphQLTestCase):
data["completion_data"],
{
user_text_input["id"]: {"user_data": {"text": "Hallo via API"}},
task_id: {"user_data": {"fileId": file_id}}
},
)
task_data = data["task_completion_data"][task_id]
self.assertDictEqual(
task_data,
{
"user_data":
{
"fileId": file_id,
"fileInfo": {
"id": file_id,
"name": 'file.txt',
"url": file_url}
}
}
)
# check DB data
db_entry = AssignmentCompletion.objects.get(
assignment_user=self.student,
@ -93,6 +131,7 @@ class AttendanceCourseUserMutationTestCase(GraphQLTestCase):
db_entry.completion_data,
{
user_text_input["id"]: {"user_data": {"text": "Hallo via API"}},
task_id: {"user_data": {"fileId": file_id}}
},
)
@ -136,6 +175,7 @@ class AttendanceCourseUserMutationTestCase(GraphQLTestCase):
data["completion_data"],
{
user_text_input["id"]: {"user_data": {"text": "Hallo via API 2"}},
task_id: {"user_data": {"fileId": file_id}}
},
)
@ -151,6 +191,7 @@ class AttendanceCourseUserMutationTestCase(GraphQLTestCase):
db_entry.completion_data,
{
user_text_input["id"]: {"user_data": {"text": "Hallo via API 2"}},
task_id: {"user_data": {"fileId": file_id}}
},
)
@ -227,7 +268,7 @@ class AttendanceCourseUserMutationTestCase(GraphQLTestCase):
user_text_input = find_first(
subtasks,
pred=lambda x: (value := x.get("value"))
and value.get("text", "").startswith(
and value.get("text", "").startswith(
"Gibt es zusätzliche Deckungen, die du der Person empfehlen würdest?"
),
)

View File

@ -92,7 +92,7 @@ class CourseSessionAttendanceCourse(models.Model):
class CourseSessionAssignment(models.Model):
"""
Auftrag
- Geletitete Fallarbeit ist eine speziefische ausprägung eines Auftrags (assignment_type)
- Geleitete Fallarbeit ist eine spezifische Ausprägung eines Auftrags (assignment_type)
"""
@ -138,8 +138,8 @@ class CourseSessionAssignment(models.Model):
url_expert = f"/course/{self.course_session.course.slug}/cockpit/assignment/{self.learning_content_id}?courseSessionId={self.course_session.id}"
if assignment_type in (
AssignmentType.CASEWORK.value,
AssignmentType.PREP_ASSIGNMENT.value,
AssignmentType.CASEWORK.value,
AssignmentType.PREP_ASSIGNMENT.value,
):
if not self.submission_deadline_id:
self.submission_deadline = DueDate.objects.create(

View File

@ -47,16 +47,21 @@ def s3_get_credentials() -> S3Credentials:
def s3_get_client():
credentials = s3_get_credentials()
# This is needed until https://github.com/boto/boto3/issues/3015 is fixed
s3 = boto3.client('s3', region_name=credentials.region_name)
endpoint_url = s3.meta.endpoint_url
return boto3.client(
service_name="s3",
aws_access_key_id=credentials.access_key_id,
aws_secret_access_key=credentials.secret_access_key,
region_name=credentials.region_name,
endpoint_url=endpoint_url
)
def s3_generate_presigned_post(
*, file_path: str, file_type: str, file_name: str
*, file_path: str, file_type: str, file_name: str
) -> Dict[str, Any]:
credentials = s3_get_credentials()
s3_client = s3_get_client()
@ -99,6 +104,7 @@ def s3_generate_presigned_post(
["starts-with", "$Content-Disposition", ""],
],
ExpiresIn=expires_in,
)
return presigned_data

View File

@ -0,0 +1,6 @@
from rest_framework import serializers
class PresignInputSerializer(serializers.Serializer):
file_name = serializers.CharField()
file_type = serializers.CharField()

View File

@ -43,7 +43,7 @@ class FileStandardUploadService:
self.file_obj = file_obj
def _infer_file_name_and_type(
self, file_name: str = "", file_type: str = ""
self, file_name: str = "", file_type: str = ""
) -> Tuple[str, str]:
if not file_name:
file_name = self.file_obj.name
@ -80,7 +80,7 @@ class FileStandardUploadService:
@transaction.atomic
def update(
self, file: UploadFile, file_name: str = "", file_type: str = ""
self, file: UploadFile, file_name: str = "", file_type: str = ""
) -> UploadFile:
_validate_file_size(self.file_obj)
@ -114,7 +114,7 @@ class FileDirectUploadService:
@transaction.atomic
def start(
self, file_name: str, file_type: str
self, file_name: str, file_type: str
) -> Tuple[UploadFile, Dict[str, Any]]:
file = UploadFile(
original_file_name=file_name,
@ -134,19 +134,17 @@ class FileDirectUploadService:
file.file = file.file.field.attr_class(file, file.file.field, upload_path)
file.save()
presigned_data: Dict[str, Any] = {}
if settings.FILE_UPLOAD_STORAGE == FileUploadStorage.S3.value:
presigned_data = s3_generate_presigned_post(
pre_signed_data = s3_generate_presigned_post(
file_path=upload_path, file_type=file.file_type, file_name=file_name
)
else:
presigned_data = {
pre_signed_data = {
"url": file_generate_local_upload_url(file_id=str(file.id)),
}
return file, presigned_data
return file, pre_signed_data
@transaction.atomic
def finish(self, *, file: UploadFile) -> UploadFile:

View File

@ -0,0 +1,33 @@
from rest_framework.decorators import api_view
from rest_framework.response import Response
from vbv_lernwelt.files.serializers import PresignInputSerializer
from vbv_lernwelt.files.services import FileDirectUploadService
@api_view(["POST"])
def presign(request):
print("presign", request.data)
if not request.user.is_authenticated:
return Response(status=401)
serializer = PresignInputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
service = FileDirectUploadService(request.user)
upload_file, pre_signed_data = service.start(
file_name=serializer.validated_data["file_name"], file_type=serializer.validated_data["file_type"]
)
return Response(
data={
"pre_sign": pre_signed_data,
"file_info": {
"id": upload_file.id,
"name": upload_file.original_file_name,
"url": upload_file.url,
}
}
)