Merge branch 'develop' into feature/VBV-597-umsetzung-cockpit-lernbegleitung

This commit is contained in:
Reto Aebersold 2023-12-12 10:02:08 +01:00
commit e5ad3f08d2
103 changed files with 3072 additions and 721 deletions

View File

@ -87,10 +87,14 @@ def main(app_name, image_name, environment_file):
"IT_DJANGO_SECRET_KEY": env.str(
"IT_DJANGO_SECRET_KEY", generate_random_string(63)
),
"AWS_S3_ACCESS_KEY_ID": env.str("AWS_S3_ACCESS_KEY_ID", ""),
"AWS_S3_ACCESS_KEY_ID": env.str(
"AWS_S3_ACCESS_KEY_ID", "AKIAZJLREPUVWNBTJ5VY"
),
"AWS_S3_SECRET_ACCESS_KEY": env.str("AWS_S3_SECRET_ACCESS_KEY", ""),
"AWS_S3_REGION_NAME": "eu-central-1",
"AWS_STORAGE_BUCKET_NAME": "myvbv-dev.iterativ.ch",
"AWS_S3_REGION_NAME": env.str("AWS_S3_REGION_NAME", "eu-central-1"),
"AWS_STORAGE_BUCKET_NAME": env.str(
"AWS_STORAGE_BUCKET_NAME", "myvbv-dev.iterativ.ch"
),
"FILE_UPLOAD_STORAGE": "s3",
"IT_DJANGO_DEBUG": "false",
"IT_SERVE_VUE": "false",

View File

@ -17,14 +17,14 @@ const 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": 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 task_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 solution_sample {\n id\n url\n }\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 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 ...CoursePageFields\n circle {\n id\n title\n slug\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 enable_circle_documents\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,
"\n query courseQuery($slug: String!) {\n course(slug: $slug) {\n id\n title\n slug\n category_name\n enable_circle_documents\n action_competences {\n competence_id\n ...CoursePageFields\n performance_criteria {\n competence_id\n learning_unit {\n id\n slug\n evaluate_url\n }\n ...CoursePageFields\n }\n }\n learning_path {\n ...CoursePageFields\n topics {\n is_visible\n ...CoursePageFields\n circles {\n description\n goals\n ...CoursePageFields\n learning_sequences {\n icon\n ...CoursePageFields\n learning_units {\n evaluate_url\n ...CoursePageFields\n performance_criteria {\n ...CoursePageFields\n }\n learning_contents {\n can_user_self_toggle_course_completion\n content_url\n minutes\n description\n ...CoursePageFields\n ... on LearningContentAssignmentObjectType {\n assignment_type\n content_assignment {\n id\n assignment_type\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentEdoniqTestObjectType {\n checkbox_text\n has_extended_time_test\n content_assignment {\n id\n assignment_type\n }\n competence_certificate {\n ...CoursePageFields\n }\n }\n ... on LearningContentRichTextObjectType {\n text\n }\n }\n }\n }\n }\n }\n }\n }\n }\n": types.CourseQueryDocument,
"\n query dashboardConfig {\n dashboard_config {\n id\n slug\n name\n dashboard_type\n }\n }\n": types.DashboardConfigDocument,
"\n query dashboardProgress($courseId: ID!) {\n course_progress(course_id: $courseId) {\n _id\n course_id\n session_to_continue_id\n competence {\n _id\n total_count\n success_count\n fail_count\n }\n assignment {\n _id\n total_count\n points_max_count\n points_achieved_count\n }\n }\n }\n": types.DashboardProgressDocument,
"\n query courseStatistics($courseId: ID!) {\n course_statistics(course_id: $courseId) {\n _id\n course_id\n course_title\n course_slug\n course_session_properties {\n _id\n sessions {\n id\n name\n }\n generations\n circles {\n id\n name\n }\n }\n course_session_selection_ids\n course_session_selection_metrics {\n _id\n session_count\n participant_count\n expert_count\n }\n attendance_day_presences {\n _id\n records {\n _id\n course_session_id\n generation\n circle_id\n due_date\n participants_present\n participants_total\n details_url\n }\n summary {\n _id\n days_completed\n participants_present\n }\n }\n feedback_responses {\n _id\n records {\n _id\n course_session_id\n generation\n circle_id\n experts\n satisfaction_average\n satisfaction_max\n details_url\n }\n summary {\n _id\n satisfaction_average\n satisfaction_max\n total_responses\n }\n }\n assignments {\n _id\n summary {\n _id\n completed_count\n average_passed\n }\n records {\n _id\n course_session_id\n course_session_assignment_id\n circle_id\n generation\n assignment_title\n assignment_type_translation_key\n details_url\n deadline\n metrics {\n _id\n passed_count\n failed_count\n unranked_count\n ranking_completed\n average_passed\n }\n }\n }\n competences {\n _id\n summary {\n _id\n success_total\n fail_total\n }\n records {\n _id\n course_session_id\n generation\n circle_id\n title\n success_count\n fail_count\n details_url\n }\n }\n }\n }\n": types.CourseStatisticsDocument,
"\n mutation SendFeedbackMutation(\n $courseSessionId: ID!\n $learningContentId: ID!\n $data: GenericScalar!\n $submitted: Boolean\n ) {\n send_feedback(\n course_session_id: $courseSessionId\n learning_content_page_id: $learningContentId\n data: $data\n submitted: $submitted\n ) {\n feedback_response {\n id\n data\n submitted\n }\n errors {\n field\n messages\n }\n }\n }\n": types.SendFeedbackMutationDocument,
"\n mutation SendFeedbackMutation(\n $courseSessionId: ID!\n $learningContentId: ID!\n $learningContentType: String!\n $data: GenericScalar!\n $submitted: Boolean\n ) {\n send_feedback(\n course_session_id: $courseSessionId\n learning_content_page_id: $learningContentId\n learning_content_type: $learningContentType\n data: $data\n submitted: $submitted\n ) {\n feedback_response {\n id\n data\n submitted\n }\n errors {\n field\n messages\n }\n }\n }\n": types.SendFeedbackMutationDocument,
};
/**
@ -60,7 +60,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 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"];
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 solution_sample {\n id\n url\n }\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 solution_sample {\n id\n url\n }\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.
*/
@ -88,7 +88,7 @@ export function graphql(source: "\n query courseStatistics($courseId: ID!) {\n
/**
* 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 SendFeedbackMutation(\n $courseSessionId: ID!\n $learningContentId: ID!\n $data: GenericScalar!\n $submitted: Boolean\n ) {\n send_feedback(\n course_session_id: $courseSessionId\n learning_content_page_id: $learningContentId\n data: $data\n submitted: $submitted\n ) {\n feedback_response {\n id\n data\n submitted\n }\n errors {\n field\n messages\n }\n }\n }\n"): (typeof documents)["\n mutation SendFeedbackMutation(\n $courseSessionId: ID!\n $learningContentId: ID!\n $data: GenericScalar!\n $submitted: Boolean\n ) {\n send_feedback(\n course_session_id: $courseSessionId\n learning_content_page_id: $learningContentId\n data: $data\n submitted: $submitted\n ) {\n feedback_response {\n id\n data\n submitted\n }\n errors {\n field\n messages\n }\n }\n }\n"];
export function graphql(source: "\n mutation SendFeedbackMutation(\n $courseSessionId: ID!\n $learningContentId: ID!\n $learningContentType: String!\n $data: GenericScalar!\n $submitted: Boolean\n ) {\n send_feedback(\n course_session_id: $courseSessionId\n learning_content_page_id: $learningContentId\n learning_content_type: $learningContentType\n data: $data\n submitted: $submitted\n ) {\n feedback_response {\n id\n data\n submitted\n }\n errors {\n field\n messages\n }\n }\n }\n"): (typeof documents)["\n mutation SendFeedbackMutation(\n $courseSessionId: ID!\n $learningContentId: ID!\n $learningContentType: String!\n $data: GenericScalar!\n $submitted: Boolean\n ) {\n send_feedback(\n course_session_id: $courseSessionId\n learning_content_page_id: $learningContentId\n learning_content_type: $learningContentType\n data: $data\n submitted: $submitted\n ) {\n feedback_response {\n id\n data\n submitted\n }\n errors {\n field\n messages\n }\n }\n }\n"];
export function graphql(source: string) {
return (documents as any)[source] ?? {};

File diff suppressed because one or more lines are too long

View File

@ -9,7 +9,8 @@ type Query {
learning_content_media_library: LearningContentMediaLibraryObjectType
learning_content_assignment: LearningContentAssignmentObjectType
learning_content_attendance_course: LearningContentAttendanceCourseObjectType
learning_content_feedback: LearningContentFeedbackObjectType
learning_content_feedback_uk: LearningContentFeedbackUKObjectType
learning_content_feedback_vv: LearningContentFeedbackVVObjectType
learning_content_learning_module: LearningContentLearningModuleObjectType
learning_content_knowledge_assessment: LearningContentKnowledgeAssessmentObjectType
learning_content_placeholder: LearningContentPlaceholderObjectType
@ -486,6 +487,7 @@ type AssignmentObjectType implements CoursePageInterface {
max_points: Int
learning_content: LearningContentInterface
completion(course_session_id: ID!, learning_content_page_id: ID, assignment_user_id: UUID): AssignmentCompletionObjectType
solution_sample: ContentDocumentObjectType
}
"""An enumeration."""
@ -605,6 +607,15 @@ schema (one of the key benefits of GraphQL).
"""
scalar JSONString
type ContentDocumentObjectType {
id: ID!
display_text: String!
description: String!
link_display_text: String!
thumbnail: String!
url: String
}
"""An enumeration."""
enum LearnpathLearningContentAssignmentAssignmentTypeChoices {
"""PRAXIS_ASSIGNMENT"""
@ -708,7 +719,23 @@ type LearningContentMediaLibraryObjectType implements CoursePageInterface & Lear
circle: CircleLightObjectType
}
type LearningContentFeedbackObjectType implements CoursePageInterface & LearningContentInterface {
type LearningContentFeedbackUKObjectType implements CoursePageInterface & LearningContentInterface {
id: ID!
title: String!
slug: String!
content_type: String!
live: Boolean!
translation_key: String!
frontend_url: String!
course: CourseObjectType
minutes: Int
description: String!
content_url: String!
can_user_self_toggle_course_completion: Boolean!
circle: CircleLightObjectType
}
type LearningContentFeedbackVVObjectType implements CoursePageInterface & LearningContentInterface {
id: ID!
title: String!
slug: String!
@ -834,7 +861,7 @@ type CompetenceCertificateListObjectType implements CoursePageInterface {
}
type Mutation {
send_feedback(course_session_id: ID!, data: GenericScalar, learning_content_page_id: ID!, submitted: Boolean = false): SendFeedbackMutation
send_feedback(course_session_id: ID!, data: GenericScalar, learning_content_page_id: ID!, learning_content_type: String!, submitted: Boolean = false): SendFeedbackMutation
update_course_session_attendance_course_users(attendance_user_list: [AttendanceUserInputType]!, id: ID!): AttendanceCourseUserMutation
upsert_assignment_completion(assignment_id: ID!, assignment_user_id: UUID, completion_data_string: String, completion_status: AssignmentCompletionStatus, course_session_id: ID!, evaluation_passed: Boolean, evaluation_points: Float, initialize_completion: Boolean, learning_content_page_id: ID): AssignmentCompletionMutation
}

View File

@ -23,6 +23,7 @@ export const CompetenceCertificateObjectType = "CompetenceCertificateObjectType"
export const CompetencePerformanceStatisticsSummaryType = "CompetencePerformanceStatisticsSummaryType";
export const CompetenceRecordStatisticsType = "CompetenceRecordStatisticsType";
export const CompetencesStatisticsType = "CompetencesStatisticsType";
export const ContentDocumentObjectType = "ContentDocumentObjectType";
export const CoreUserLanguageChoices = "CoreUserLanguageChoices";
export const CourseObjectType = "CourseObjectType";
export const CoursePageInterface = "CoursePageInterface";
@ -54,7 +55,8 @@ export const LearningContentAssignmentObjectType = "LearningContentAssignmentObj
export const LearningContentAttendanceCourseObjectType = "LearningContentAttendanceCourseObjectType";
export const LearningContentDocumentListObjectType = "LearningContentDocumentListObjectType";
export const LearningContentEdoniqTestObjectType = "LearningContentEdoniqTestObjectType";
export const LearningContentFeedbackObjectType = "LearningContentFeedbackObjectType";
export const LearningContentFeedbackUKObjectType = "LearningContentFeedbackUKObjectType";
export const LearningContentFeedbackVVObjectType = "LearningContentFeedbackVVObjectType";
export const LearningContentInterface = "LearningContentInterface";
export const LearningContentKnowledgeAssessmentObjectType = "LearningContentKnowledgeAssessmentObjectType";
export const LearningContentLearningModuleObjectType = "LearningContentLearningModuleObjectType";

View File

@ -46,6 +46,10 @@ export const ASSIGNMENT_COMPLETION_QUERY = graphql(`
tasks
title
translation_key
solution_sample {
id
url
}
competence_certificate {
...CoursePageFields
}

View File

@ -87,6 +87,8 @@
"performanceObjectivesTitle": "Leistungsziele",
"showAssessmentDocument": "Bewertungsinstrument anzeigen",
"submissionNotificationDisclaimer": "{{name}} wird deine Ergebnisse bewerten. Du wirst per Benachrichtigung informiert, sobald die Bewertung für dich freigegeben wurde.",
"submissionShowSampleSolution": "Musterlösung anzeigen",
"submissionShowSampleSolutionText": "Hier findest du eine mögliche Lösung zu deinen Aufgaben. Vorgehen und Prozesse in deiner Organisation können von dieser Lösung abweichen.",
"submitAssignment": "Ergebnisse abgeben",
"taskDefinition": "Bearbeite die Teilaufgaben und dokumentiere deine Ergebnisse.",
"taskDefinitionTitle": "Aufgabenstellung",

View File

@ -91,6 +91,7 @@ const appointments = computed(() => {
.allDueDates()
.filter(
(dueDate) =>
hasDueDate(dueDate) &&
isMatchingCourse(dueDate) &&
isMatchingSession(dueDate) &&
isMatchingCircle(dueDate)
@ -108,6 +109,10 @@ const isMatchingCircle = (dueDate: DueDate) =>
const isMatchingCourse = (dueDate: DueDate) =>
courseSessions.value.map((cs) => cs.id).includes(dueDate.course_session_id);
const hasDueDate = (dueDate: DueDate) => {
return dueDate.start || dueDate.end;
};
const numAppointmentsToShow = ref(7);
const canLoadMore = computed(() => {
return numAppointmentsToShow.value < appointments.value.length;

View File

@ -18,70 +18,24 @@
</span>
{{ $t("feedback.feedbackPageInfo") }}
</p>
<ol v-if="feedbackData.amount > 0">
<li
v-for="(question, i) in orderedQuestions"
:key="i"
:data-cy="`question-${i + 1}`"
>
<RatingScale
v-if="ratingKeys.includes(question.key)"
class="mb-8 bg-white"
:ratings="feedbackData.questions[question.key]"
:title="`${$t('feedback.questionTitle')} ${i + 1}`"
:text="question.question"
/>
<VerticalBarChart
v-else-if="verticalChartKyes.includes(question.key)"
class="mb-8 bg-white"
:title="`${$t('feedback.questionTitle')} ${i + 1}`"
:ratings="feedbackData.questions[question.key]"
:text="question.question"
:ratio="0.2"
/>
<OpenFeedback
v-else-if="
openKeys.includes(question.key) && feedbackData.questions[question.key]
"
class="mb-8 bg-white"
:title="`${$t('feedback.questionTitle')} ${i + 1}`"
:text="question.question"
:answers="feedbackData.questions[question.key].filter((a: string) => a !== '')"
></OpenFeedback>
<HorizontalBarChart
v-else-if="
horizontalChartKeys.includes(question.key) &&
feedbackData.questions[question.key]
"
class="mb-8 bg-white"
:title="`${$t('feedback.questionTitle')} ${i}`"
:text="question.question"
:items="feedbackData.questions[question.key].map((a: string) => `${a}%`)"
/>
</li>
</ol>
<FeedbackPageVV v-if="feedbackType === 'vv'" :feedback-data="feedbackData" />
<FeedbackPageUK
v-else-if="feedbackType === 'uk'"
:feedback-data="feedbackData"
/>
</main>
</div>
</div>
</template>
<script setup lang="ts">
import HorizontalBarChart from "@/components/ui/HorizontalBarChart.vue";
import OpenFeedback from "@/components/ui/OpenFeedback.vue";
import RatingScale from "@/components/ui/RatingScale.vue";
import VerticalBarChart from "@/components/ui/VerticalBarChart.vue";
import { useCurrentCourseSession } from "@/composables";
import { itGet } from "@/fetchHelpers";
import * as log from "loglevel";
import { onMounted, ref } from "vue";
import { useTranslation } from "i18next-vue";
interface FeedbackData {
amount: number;
questions: {
[key: string]: any;
};
}
import type { FeedbackData, FeedbackType } from "@/types";
import FeedbackPageVV from "@/pages/cockpit/FeedbackPageVV.vue";
import FeedbackPageUK from "@/pages/cockpit/FeedbackPageUK.vue";
const props = defineProps<{
courseSlug: string;
@ -91,72 +45,21 @@ const props = defineProps<{
log.debug("FeedbackPage created", props.circleId);
const courseSession = useCurrentCourseSession();
const { t } = useTranslation();
const orderedQuestions = [
{
key: "satisfaction",
question: t("feedback.satisfactionLabel"),
},
{
key: "goal_attainment",
question: t("feedback.goalAttainmentLabel"),
},
{
key: "proficiency",
question: t("feedback.proficiencyLabel"),
},
{
key: "preparation_task_clarity",
question: t("feedback.preparationTaskClarityLabel"),
},
{
key: "instructor_competence",
question: t("feedback.instructorCompetenceLabel"),
},
{
key: "instructor_respect",
question: t("feedback.instructorRespectLabel"),
},
{
key: "instructor_open_feedback",
question: t("feedback.instructorOpenFeedbackLabel"),
},
{
key: "would_recommend",
question: t("feedback.recommendLabel"),
},
{
key: "course_negative_feedback",
question: t("feedback.courseNegativeFeedbackLabel"),
},
{
key: "course_positive_feedback",
question: t("feedback.coursePositiveFeedbackLabel"),
},
];
const ratingKeys = [
"satisfaction",
"goal_attainment",
"instructor_competence",
"instructor_respect",
];
const verticalChartKyes = ["preparation_task_clarity", "would_recommend"];
const horizontalChartKeys = ["proficiency"];
const openKeys = [
"course_negative_feedback",
"course_positive_feedback",
"instructor_open_feedback",
];
const feedbackData = ref<FeedbackData | undefined>(undefined);
const feedbackType = ref<FeedbackType | undefined>(undefined);
onMounted(async () => {
log.debug("FeedbackPage mounted");
feedbackData.value = await itGet(
`/api/core/feedback/${courseSession.value.id}/${props.circleId}`
);
log.debug("FeedbackPage feedbackData", feedbackData.value);
if (
feedbackData.value &&
["uk", "vv"].includes(feedbackData.value?.feedbackType ?? "")
) {
feedbackType.value = feedbackData.value.feedbackType;
}
});
</script>

View File

@ -0,0 +1,84 @@
<template>
<FeedbackResults
:ordered-questions="orderedQuestions"
:feedback-data="feedbackData"
:rating-keys="ratingKeys"
:vertical-chart-keys="verticalChartKeys"
:horizontal-chart-keys="horizontalChartKeys"
:open-keys="openKeys"
/>
</template>
<script setup lang="ts">
import FeedbackResults from "@/pages/cockpit/FeedbackResults.vue";
import type { FeedbackData } from "@/types";
import * as log from "loglevel";
import { useTranslation } from "i18next-vue";
defineProps<{
feedbackData: FeedbackData;
}>();
log.debug("FeedbackPageUK created");
const { t } = useTranslation();
const orderedQuestions = [
{
key: "satisfaction",
question: t("feedback.satisfactionLabel"),
},
{
key: "goal_attainment",
question: t("feedback.goalAttainmentLabel"),
},
{
key: "proficiency",
question: t("feedback.proficiencyLabel"),
},
{
key: "preparation_task_clarity",
question: t("feedback.preparationTaskClarityLabel"),
},
{
key: "instructor_competence",
question: t("feedback.instructorCompetenceLabel"),
},
{
key: "instructor_respect",
question: t("feedback.instructorRespectLabel"),
},
{
key: "instructor_open_feedback",
question: t("feedback.instructorOpenFeedbackLabel"),
},
{
key: "would_recommend",
question: t("feedback.recommendLabel"),
},
{
key: "course_negative_feedback",
question: t("feedback.courseNegativeFeedbackLabel"),
},
{
key: "course_positive_feedback",
question: t("feedback.coursePositiveFeedbackLabel"),
},
];
const ratingKeys = [
"satisfaction",
"goal_attainment",
"instructor_competence",
"instructor_respect",
];
const verticalChartKeys = ["preparation_task_clarity", "would_recommend"];
const horizontalChartKeys = ["proficiency"];
const openKeys = [
"course_negative_feedback",
"course_positive_feedback",
"instructor_open_feedback",
];
</script>
<style scoped></style>

View File

@ -0,0 +1,63 @@
<template>
<FeedbackResults
:ordered-questions="orderedQuestions"
:feedback-data="feedbackData"
:rating-keys="ratingKeys"
:vertical-chart-keys="verticalChartKeys"
:horizontal-chart-keys="horizontalChartKeys"
:open-keys="openKeys"
/>
</template>
<script setup lang="ts">
import FeedbackResults from "@/pages/cockpit/FeedbackResults.vue";
import type { FeedbackData } from "@/types";
import * as log from "loglevel";
import { useTranslation } from "i18next-vue";
defineProps<{
feedbackData: FeedbackData;
}>();
log.debug("FeedbackPageVV created");
const { t } = useTranslation();
const orderedQuestions = [
{
key: "satisfaction",
question: t("feedback.satisfactionLabel"),
},
{
key: "goal_attainment",
question: t("feedback.goalAttainmentLabel"),
},
{
key: "proficiency",
question: t("feedback.proficiencyLabelVV"),
},
{
key: "preparation_task_clarity",
question: t("feedback.praxisAssignmentClarity"),
},
{
key: "would_recommend",
question: t("feedback.recommendLabelVV"),
},
{
key: "course_negative_feedback",
question: t("feedback.courseNegativeFeedbackLabel"),
},
{
key: "course_positive_feedback",
question: t("feedback.coursePositiveFeedbackLabel"),
},
];
const ratingKeys = ["satisfaction", "goal_attainment"];
const verticalChartKeys = ["preparation_task_clarity", "would_recommend"];
const horizontalChartKeys = ["proficiency"];
const openKeys = ["course_negative_feedback", "course_positive_feedback"];
</script>
<style scoped></style>

View File

@ -0,0 +1,77 @@
<template>
<ol v-if="feedbackData.amount > 0">
<li
v-for="(question, i) in orderedQuestions"
:key="i"
:data-cy="`question-${i + 1}`"
>
<RatingScale
v-if="ratingKeys.includes(question.key)"
class="mb-8 bg-white"
:ratings="feedbackData.questions[question.key]"
:title="`${$t('feedback.questionTitle')} ${i + 1}`"
:text="question.question"
/>
<VerticalBarChart
v-else-if="verticalChartKeys.includes(question.key)"
class="mb-8 bg-white"
:title="`${$t('feedback.questionTitle')} ${i + 1}`"
:ratings="feedbackData.questions[question.key]"
:text="question.question"
:ratio="0.2"
/>
<OpenFeedback
v-else-if="
openKeys.includes(question.key) && feedbackData.questions[question.key]
"
class="mb-8 bg-white"
:title="`${$t('feedback.questionTitle')} ${i + 1}`"
:text="question.question"
:answers="feedbackData.questions[question.key].filter((a: string) => a !== '')"
></OpenFeedback>
<HorizontalBarChart
v-else-if="
horizontalChartKeys.includes(question.key) &&
feedbackData.questions[question.key]
"
class="mb-8 bg-white"
:title="`${$t('feedback.questionTitle')} ${i + 1}`"
:text="question.question"
:items="feedbackData.questions[question.key].map((a: string) => `${a}%`)"
/>
</li>
</ol>
</template>
<script setup lang="ts">
import HorizontalBarChart from "@/components/ui/HorizontalBarChart.vue";
import OpenFeedback from "@/components/ui/OpenFeedback.vue";
import RatingScale from "@/components/ui/RatingScale.vue";
import VerticalBarChart from "@/components/ui/VerticalBarChart.vue";
import type { FeedbackData } from "@/types";
import * as log from "loglevel";
interface Props {
orderedQuestions?: {
key: string;
question: string;
}[];
feedbackData: FeedbackData;
ratingKeys?: string[];
verticalChartKeys?: string[];
horizontalChartKeys?: string[];
openKeys?: string[];
}
withDefaults(defineProps<Props>(), {
orderedQuestions: () => [],
ratingKeys: () => [],
verticalChartKeys: () => [],
horizontalChartKeys: () => [],
openKeys: () => [],
});
log.debug("FeedbackBasePage created");
</script>
<style scoped></style>

View File

@ -52,7 +52,8 @@ const submittables = computed(() => {
const learningContents = circleFlatLearningContents(circle).filter(
(lc) =>
lc.content_type === "learnpath.LearningContentAssignment" ||
lc.content_type === "learnpath.LearningContentFeedback" ||
lc.content_type === "learnpath.LearningContentFeedbackUK" ||
lc.content_type === "learnpath.LearningContentFeedbackVV" ||
lc.content_type === "learnpath.LearningContentEdoniqTest"
);
@ -72,7 +73,10 @@ const submittables = computed(() => {
});
const isFeedback = (lc: LearningContent) => {
return lc.content_type === "learnpath.LearningContentFeedback";
return (
lc.content_type === "learnpath.LearningContentFeedbackUK" ||
lc.content_type === "learnpath.LearningContentFeedbackVV"
);
};
const isAssignment = (lc: LearningContent) => {

View File

@ -14,12 +14,13 @@ import type { Component } from "vue";
import { computed, onUnmounted } from "vue";
import AssignmentBlock from "./blocks/AssignmentBlock.vue";
import AttendanceCourseBlock from "./blocks/AttendanceCourseBlock.vue";
import FeedbackBlock from "./feedback/FeedbackBlock.vue";
import IframeBlock from "./blocks/IframeBlock.vue";
import MediaLibraryBlock from "./blocks/MediaLibraryBlock.vue";
import PlaceholderBlock from "./blocks/PlaceholderBlock.vue";
import RichTextBlock from "./blocks/RichTextBlock.vue";
import VideoBlock from "./blocks/VideoBlock.vue";
import FeedbackBlockUK from "./feedback/FeedbackBlockUK.vue";
import FeedbackBlockVV from "./feedback/FeedbackBlockVV.vue";
import { getPreviousRoute } from "@/router/history";
import { stringifyParse } from "@/utils/utils";
import { useCourseDataWithCompletion } from "@/composables";
@ -42,7 +43,8 @@ const COMPONENTS: Record<LearningContentContentType, Component> = {
"learnpath.LearningContentAssignment": AssignmentBlock,
"learnpath.LearningContentAttendanceCourse": AttendanceCourseBlock,
"learnpath.LearningContentDocumentList": DocumentListBlock,
"learnpath.LearningContentFeedback": FeedbackBlock,
"learnpath.LearningContentFeedbackUK": FeedbackBlockUK,
"learnpath.LearningContentFeedbackVV": FeedbackBlockVV,
"learnpath.LearningContentLearningModule": IframeBlock,
"learnpath.LearningContentKnowledgeAssessment": IframeBlock,
"learnpath.LearningContentMediaLibrary": MediaLibraryBlock,

View File

@ -104,6 +104,14 @@ const onEditTask = (task: AssignmentTask) => {
emit("editTask", task);
};
const openSolutionSample = () => {
const url = props.assignment.solution_sample?.url ?? "";
if (props.assignment.solution_sample) {
window.open(url, "_blank");
}
};
const onSubmit = async () => {
try {
await upsertAssignmentCompletionMutation.executeMutation({
@ -119,14 +127,17 @@ const onSubmit = async () => {
bustItGetCache(
`/api/course/completion/${courseSession.value.id}/${useUserStore().id}/`
);
eventBus.emit("finishedLearningContent", true);
// if solution sample is available, do not close the assigment automatically
if (!props.assignment.solution_sample) {
eventBus.emit("finishedLearningContent", true);
}
} catch (error) {
log.error("Could not submit assignment", error);
}
};
</script>
<template>
<div class="w-full border border-gray-400 p-8">
<div class="w-full border border-gray-400 p-8" data-cy="confirm-container">
<h3 class="heading-3 border-b border-gray-400 pb-6">
{{ $t("assignment.submitAssignment") }}
</h3>
@ -202,6 +213,26 @@ const onSubmit = async () => {
$t("assignment.submissionNotificationDisclaimer", { name: circleExpertName })
}}
</p>
<div
v-if="props.assignment.solution_sample"
class="pt-2"
data-cy="show-sample-solution"
>
<p>
{{ $t("assignment.submissionShowSampleSolutionText") }}
</p>
<ItButton
class="mt-6"
variant="primary"
size="normal"
:disabled="false"
data-cy="show-sample-solution-button"
@click="openSolutionSample"
>
<p>{{ $t("assignment.submissionShowSampleSolution") }}</p>
</ItButton>
</div>
</div>
</div>
<AssignmentSubmissionResponses

View File

@ -1,35 +1,32 @@
<script setup lang="ts">
import ItRadioGroup from "@/components/ui/ItRadioGroup.vue";
import ItTextarea from "@/components/ui/ItTextarea.vue";
import { graphql } from "@/gql";
import FeedbackCompletition from "@/pages/learningPath/learningContentPage/feedback/FeedbackCompletition.vue";
import {
PERCENTAGES,
RATINGS,
YES_NO,
} from "@/pages/learningPath/learningContentPage/feedback/feedback.constants";
import LearningContentMultiLayout from "@/pages/learningPath/learningContentPage/layouts/LearningContentMultiLayout.vue";
import type { LearningContentFeedback } from "@/types";
import type { LearningContentFeedbackUK, LearningContentFeedbackVV } from "@/types";
import { useMutation } from "@urql/vue";
import { useRouteQuery } from "@vueuse/router";
import log from "loglevel";
import { computed, onMounted, reactive, ref } from "vue";
import { useTranslation } from "i18next-vue";
import { useCourseSessionDetailQuery, useCurrentCourseSession } from "@/composables";
import { bustItGetCache } from "@/fetchHelpers";
import { useUserStore } from "@/stores/user";
const props = defineProps<{
content: LearningContentFeedback;
content: LearningContentFeedbackVV | LearningContentFeedbackUK;
stepLabels: string[];
questionData: any[];
introduction: string;
title: string;
completionTitle: string;
completionDescription: string;
showAvatar: boolean;
}>();
const courseSession = useCurrentCourseSession();
const courseSessionDetailResult = useCourseSessionDetailQuery();
const { t } = useTranslation();
const stepNo = useRouteQuery("step", "0", { transform: Number, mode: "push" });
const title = computed(
() => `«${props.content.circle?.title}»: ${t("feedback.areYouSatisfied")}`
);
const title = computed(() => `«${props.content.circle?.title}»: ${props.title}`);
const circleExperts = computed(() => {
if (props.content?.circle?.slug) {
@ -38,34 +35,29 @@ const circleExperts = computed(() => {
return [];
});
const stepLabels = [
t("general.introduction"),
t("feedback.satisfactionLabel"),
t("feedback.goalAttainmentLabel"),
t("feedback.proficiencyLabel"),
t("feedback.preparationTaskClarityLabel"),
t("feedback.instructorCompetenceLabel"),
t("feedback.instructorRespectLabel"),
t("feedback.instructorOpenFeedbackLabel"),
t("feedback.recommendLabel"),
t("feedback.coursePositiveFeedbackLabel"),
t("feedback.courseNegativeFeedbackLabel"),
t("general.submission"),
];
const localStepLabels = ref(props.stepLabels);
const localQuestionData = ref(props.questionData);
const feedbackData: FeedbackData = reactive(feedbackDataFactory());
const numSteps = stepLabels.length;
const numSteps = computed(() => localStepLabels.value.length);
const textQuestionKeys = computed(() =>
props.questionData.filter((item) => isTextNode(item)).map((item) => item.modelKey)
);
const avatarUrl = computed(() => circleExperts.value[0]?.avatar_url);
// noinspection GraphQLUnresolvedReference -> mute IntelliJ warning
const sendFeedbackMutation = graphql(`
mutation SendFeedbackMutation(
$courseSessionId: ID!
$learningContentId: ID!
$learningContentType: String!
$data: GenericScalar!
$submitted: Boolean
) {
send_feedback(
course_session_id: $courseSessionId
learning_content_page_id: $learningContentId
learning_content_type: $learningContentType
data: $data
submitted: $submitted
) {
@ -90,69 +82,6 @@ interface FeedbackData {
[key: string]: number | string | null;
}
const feedbackData: FeedbackData = reactive({
satisfaction: null,
goal_attainment: null,
proficiency: null,
preparation_task_clarity: null,
instructor_competence: null,
instructor_respect: null,
instructor_open_feedback: "",
would_recommend: null,
course_positive_feedback: "",
course_negative_feedback: "",
});
const questionData = [
{
modelKey: "satisfaction",
items: RATINGS,
component: ItRadioGroup,
},
{
modelKey: "goal_attainment",
items: RATINGS,
component: ItRadioGroup,
},
{
modelKey: "proficiency",
items: PERCENTAGES,
component: ItRadioGroup,
},
{
modelKey: "preparation_task_clarity",
items: YES_NO,
component: ItRadioGroup,
},
{
modelKey: "instructor_competence",
items: RATINGS,
component: ItRadioGroup,
},
{
modelKey: "instructor_respect",
items: RATINGS,
component: ItRadioGroup,
},
{
modelKey: "instructor_open_feedback",
component: ItTextarea,
},
{
modelKey: "would_recommend",
items: YES_NO,
component: ItRadioGroup,
},
{
modelKey: "course_positive_feedback",
component: ItTextarea,
},
{
modelKey: "course_negative_feedback",
component: ItTextarea,
},
];
const previousStep = () => {
if (stepNo.value > 0) {
stepNo.value -= 1;
@ -160,23 +89,17 @@ const previousStep = () => {
};
const nextStep = () => {
if (stepNo.value < numSteps && hasStepValidInput(stepNo.value)) {
if (stepNo.value < numSteps.value && hasStepValidInput(stepNo.value)) {
stepNo.value += 1;
}
log.debug(`next step ${stepNo.value} of ${numSteps}`);
log.debug(`next step ${stepNo.value} of ${numSteps.value}`);
mutateFeedback(feedbackData);
};
function hasStepValidInput(stepNumber: number) {
const question = questionData[stepNumber - 1];
const question = localQuestionData.value[stepNumber - 1];
if (question) {
if (
[
"instructor_open_feedback",
"course_negative_feedback",
"course_positive_feedback",
].includes(question.modelKey)
) {
if (textQuestionKeys.value.includes(question.modelKey)) {
// text response questions need to have a "truthy" value (not "" or null)
return feedbackData[question.modelKey];
} else {
@ -192,6 +115,7 @@ function mutateFeedback(data: FeedbackData, submit = false) {
return executeMutation({
courseSessionId: courseSession.value.id,
learningContentId: props.content.id,
learningContentType: props.content.content_type,
data: data,
submitted: submit,
})
@ -199,29 +123,40 @@ function mutateFeedback(data: FeedbackData, submit = false) {
log.debug("feedback mutation result", result);
if (result.data?.send_feedback?.feedback_response?.data) {
const responseData = result.data.send_feedback.feedback_response.data;
if (!responseData.instructor_open_feedback) {
responseData.instructor_open_feedback = "";
}
if (!responseData.course_negative_feedback) {
responseData.course_negative_feedback = "";
}
if (!responseData.course_positive_feedback) {
responseData.course_positive_feedback = "";
}
textQuestionKeys.value.map((key) => {
if (!responseData[key]) {
responseData[key] = "";
}
});
Object.assign(feedbackData, responseData);
log.debug("feedback data", feedbackData);
feedbackSubmitted.value =
result.data?.send_feedback?.feedback_response?.submitted || false;
}
bustItGetCache(
`/api/course/completion/${courseSession.value.id}/${useUserStore().id}/`
);
})
.catch((e) => log.error(e));
}
function feedbackDataFactory() {
const data: FeedbackData = {};
localQuestionData.value.map((item) => {
data[item.modelKey] = isTextNode(item) ? "" : null;
});
return data;
}
function isTextNode(item: any) {
return item.component.props.placeholder;
}
onMounted(async () => {
log.debug("Feedback mounted");
await mutateFeedback({});
if (feedbackSubmitted.value) {
stepNo.value = numSteps - 1;
stepNo.value = numSteps.value - 1;
}
});
</script>
@ -246,22 +181,22 @@ onMounted(async () => {
@next="nextStep()"
>
<div>
<p v-if="stepNo === 0" class="mt-10">
{{
$t("feedback.intro", {
name: `${circleExperts[0]?.first_name} ${circleExperts[0]?.last_name}`,
})
}}
<p v-if="stepNo === 0" class="mt-10" data-cy="introduction">
{{ introduction }}
</p>
<p v-if="stepNo > 0 && stepNo + 1 < numSteps" class="pb-2">
{{ stepLabels[stepNo] }}
<p
v-if="stepNo > 0 && stepNo + 1 < numSteps"
class="pb-2"
:data-cy="`question-${stepNo}`"
>
{{ localStepLabels[stepNo] }}
</p>
<div v-for="(question, index) in questionData" :key="index">
<!-- eslint-disable -->
<!-- eslint does not like the dynamic v-model... -->
<component
:is="question.component"
v-if="index + 1 === stepNo"
v-if="index + 1 === stepNo && feedbackData != undefined"
v-model="feedbackData[question.modelKey] as any"
:items="question['items']"
:cy-key="question.modelKey"
@ -269,14 +204,11 @@ onMounted(async () => {
<!-- eslint-enable -->
</div>
<FeedbackCompletition
v-if="stepNo === 11"
:avatar-url="circleExperts[0].avatar_url"
:title="
$t('feedback.completionTitle', {
name: `${circleExperts[0].first_name} ${circleExperts[0].last_name}`,
})
"
:description="$t('feedback.completionDescription')"
v-if="stepNo === numSteps - 1"
:avatar-url="avatarUrl"
:show-avatar="showAvatar"
:title="completionTitle"
:description="completionDescription"
:feedback-sent="feedbackSubmitted"
@send-feedback="mutateFeedback(feedbackData, true)"
/>

View File

@ -0,0 +1,114 @@
<script setup lang="ts">
import ItRadioGroup from "@/components/ui/ItRadioGroup.vue";
import ItTextarea from "@/components/ui/ItTextarea.vue";
import {
PERCENTAGES,
RATINGS,
YES_NO,
} from "@/pages/learningPath/learningContentPage/feedback/feedback.constants";
import type { LearningContentFeedbackUK, LearningContentFeedbackVV } from "@/types";
import { computed } from "vue";
import { useTranslation } from "i18next-vue";
import { useCourseSessionDetailQuery } from "@/composables";
import FeedbackBase from "@/pages/learningPath/learningContentPage/feedback/FeedbackBase.vue";
const props = defineProps<{
content: LearningContentFeedbackVV | LearningContentFeedbackUK;
}>();
const courseSessionDetailResult = useCourseSessionDetailQuery();
const { t } = useTranslation();
const circleExperts = computed(() => {
if (props.content?.circle?.slug) {
return courseSessionDetailResult.filterCircleExperts(props.content.circle.slug);
}
return [];
});
const stepLabels = [
t("general.introduction"),
t("feedback.satisfactionLabel"),
t("feedback.goalAttainmentLabel"),
t("feedback.proficiencyLabel"),
t("feedback.preparationTaskClarityLabel"),
t("feedback.instructorCompetenceLabel"),
t("feedback.instructorRespectLabel"),
t("feedback.instructorOpenFeedbackLabel"),
t("feedback.recommendLabel"),
t("feedback.coursePositiveFeedbackLabel"),
t("feedback.courseNegativeFeedbackLabel"),
t("general.submission"),
];
const questionData = [
{
modelKey: "satisfaction",
items: RATINGS,
component: ItRadioGroup,
},
{
modelKey: "goal_attainment",
items: RATINGS,
component: ItRadioGroup,
},
{
modelKey: "proficiency",
items: PERCENTAGES,
component: ItRadioGroup,
},
{
modelKey: "preparation_task_clarity",
items: YES_NO,
component: ItRadioGroup,
},
{
modelKey: "instructor_competence",
items: RATINGS,
component: ItRadioGroup,
},
{
modelKey: "instructor_respect",
items: RATINGS,
component: ItRadioGroup,
},
{
modelKey: "instructor_open_feedback",
component: ItTextarea,
},
{
modelKey: "would_recommend",
items: YES_NO,
component: ItRadioGroup,
},
{
modelKey: "course_positive_feedback",
component: ItTextarea,
},
{
modelKey: "course_negative_feedback",
component: ItTextarea,
},
];
</script>
<template>
<FeedbackBase
:step-labels="stepLabels"
:question-data="questionData"
:content="props.content"
:introduction="
$t('feedback.intro', {
name: `${circleExperts[0]?.first_name} ${circleExperts[0]?.last_name}`,
})
"
:title="$t('feedback.areYouSatisfied')"
:completion-title="
$t('feedback.completionTitle', {
name: `${circleExperts[0].first_name} ${circleExperts[0].last_name}`,
})
"
:completion-description="$t('feedback.completionDescription')"
:show-avatar="true"
/>
</template>

View File

@ -0,0 +1,79 @@
<script setup lang="ts">
import ItRadioGroup from "@/components/ui/ItRadioGroup.vue";
import ItTextarea from "@/components/ui/ItTextarea.vue";
import {
PERCENTAGES,
RATINGS,
YES_NO,
} from "@/pages/learningPath/learningContentPage/feedback/feedback.constants";
import type { LearningContentFeedbackUK, LearningContentFeedbackVV } from "@/types";
import FeedbackBase from "@/pages/learningPath/learningContentPage/feedback/FeedbackBase.vue";
import { useTranslation } from "i18next-vue";
const props = defineProps<{
content: LearningContentFeedbackVV | LearningContentFeedbackUK;
}>();
const { t } = useTranslation();
const stepLabels = [
t("general.introduction"),
t("feedback.satisfactionLabel"),
t("feedback.goalAttainmentLabel"),
t("feedback.proficiencyLabelVV"),
t("feedback.praxisAssignmentClarity"),
t("feedback.recommendLabelVV"),
t("feedback.coursePositiveFeedbackLabel"),
t("feedback.courseNegativeFeedbackLabel"),
t("general.submission"),
];
const questionData = [
{
modelKey: "satisfaction",
items: RATINGS,
component: ItRadioGroup,
},
{
modelKey: "goal_attainment",
items: RATINGS,
component: ItRadioGroup,
},
{
modelKey: "proficiency",
items: PERCENTAGES,
component: ItRadioGroup,
},
{
modelKey: "preparation_task_clarity",
items: YES_NO,
component: ItRadioGroup,
},
{
modelKey: "would_recommend",
items: YES_NO,
component: ItRadioGroup,
},
{
modelKey: "course_positive_feedback",
component: ItTextarea,
},
{
modelKey: "course_negative_feedback",
component: ItTextarea,
},
];
</script>
<template>
<FeedbackBase
:step-labels="stepLabels"
:question-data="questionData"
:content="props.content"
:introduction="$t('a.feedback.introductionVV')"
:title="$t('Feedback')"
:completion-title="$t('feedback.completionDescriptionVV')"
:completion-description="$t('feedback.completionDescriptionVV')"
:show-avatar="false"
/>
</template>

View File

@ -4,7 +4,11 @@
<div
class="b-0 flex flex-col lg:flex-row lg:items-center lg:border lg:border-gray-400 lg:p-8"
>
<img :src="avatarUrl" class="mb-6 h-16 w-16 rounded-full lg:mr-12" />
<img
v-if="showAvatar"
:src="avatarUrl"
class="mb-6 h-16 w-16 rounded-full lg:mr-12"
/>
<h2 class="mb-8 block lg:hidden">{{ title }}</h2>
<div>
<p class="mb-6">{{ description }}</p>
@ -33,6 +37,7 @@ interface Props {
title?: string;
description?: string;
feedbackSent?: boolean;
showAvatar?: boolean;
}
withDefaults(defineProps<Props>(), {
@ -40,6 +45,7 @@ withDefaults(defineProps<Props>(), {
title: "",
description: "",
feedbackSent: false,
showAvatar: true,
});
defineEmits(["sendFeedback"]);

View File

@ -11,7 +11,8 @@ import type {
LearningContentAttendanceCourseObjectType,
LearningContentDocumentListObjectType,
LearningContentEdoniqTestObjectType,
LearningContentFeedbackObjectType,
LearningContentFeedbackUkObjectType,
LearningContentFeedbackVvObjectType,
LearningContentKnowledgeAssessmentObjectType,
LearningContentLearningModuleObjectType,
LearningContentMediaLibraryObjectType,
@ -68,8 +69,12 @@ export type LearningContentEdoniqTest = LearningContentEdoniqTestObjectType & {
readonly content_type: "learnpath.LearningContentEdoniqTest";
};
export type LearningContentFeedback = LearningContentFeedbackObjectType & {
readonly content_type: "learnpath.LearningContentFeedback";
export type LearningContentFeedbackVV = LearningContentFeedbackVvObjectType & {
readonly content_type: "learnpath.LearningContentFeedbackVV";
};
export type LearningContentFeedbackUK = LearningContentFeedbackUkObjectType & {
readonly content_type: "learnpath.LearningContentFeedbackUK";
};
export type LearningContentLearningModule = LearningContentLearningModuleObjectType & {
@ -102,7 +107,8 @@ export type LearningContent =
| LearningContentAttendanceCourse
| LearningContentDocumentList
| LearningContentEdoniqTest
| LearningContentFeedback
| LearningContentFeedbackUK
| LearningContentFeedbackVV
| LearningContentLearningModule
| LearningContentKnowledgeAssessment
| LearningContentMediaLibrary
@ -560,3 +566,13 @@ export type DueDate = SimpleDueDate & {
course_session_id: string;
circle: CircleLight | null;
};
export type FeedbackType = "uk" | "vv";
export interface FeedbackData {
amount: number;
questions: {
[key: string]: any;
};
feedbackType: FeedbackType;
}

View File

@ -46,7 +46,8 @@ export function learningContentTypeData(
return { title: t("learningContentTypes.test"), icon: "it-icon-lc-test" };
case "learnpath.LearningContentRichText":
return { title: t("learningContentTypes.text"), icon: "it-icon-lc-resource" };
case "learnpath.LearningContentFeedback":
case "learnpath.LearningContentFeedbackUK":
case "learnpath.LearningContentFeedbackVV":
return { title: t("learningContentTypes.feedback"), icon: "it-icon-lc-feedback" };
case "learnpath.LearningContentPlaceholder":
return {

View File

@ -1,8 +1,6 @@
import { TEST_STUDENT1_USER_ID } from "../../consts";
import { login } from "../helpers";
// Daniel: without this comment, my tool will reformat the login import out...
function completePraxisAssignment(selectExpert = false) {
cy.visit("/course/test-lehrgang/learn/reisen/mein-kundenstamm");
cy.learningContentMultiLayoutNextStep();
@ -326,16 +324,23 @@ describe("assignmentStudent.cy.js", () => {
cy.get('[data-cy="submit-assignment"]').click();
cy.get('[data-cy="success-text"]').should("exist");
// app goes back to circle view -> check if assignment is marked as completed
cy.url().should((url) => {
expect(url).to.match(/\/fahrzeug#lu-transfer?$/);
});
cy.reload();
cy.get('[data-cy="confirm-container"]')
.find('[data-cy="show-sample-solution"]')
.then(($elements) => {
if ($elements.length > 0) {
// Ist die Musterlösung da?
cy.get('[data-cy="show-sample-solution"]').should("exist");
cy.get('[data-cy="show-sample-solution-button"]').should("exist");
}
});
cy.visit("/course/test-lehrgang/learn/fahrzeug/");
cy.get(
'[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-überprüfen-einer-motorfahrzeug-versicherungspolice-checkbox"]'
).should("have.class", "cy-checked");
// reopening page should get directly to last step
//reopening page should get directly to last step
cy.visit(
"/course/test-lehrgang/learn/fahrzeug/überprüfen-einer-motorfahrzeug-versicherungspolice"
);

View File

@ -1,4 +1,4 @@
import { TEST_STUDENT1_USER_ID } from "../../consts";
import { TEST_TRAINER1_USER_ID } from "../../consts";
import { login } from "../helpers";
describe("assignmentTrainer.cy.js", () => {
@ -85,8 +85,8 @@ describe("assignmentTrainer.cy.js", () => {
cy.wait(500);
// load AssignmentCompletion from DB and check
cy.loadAssignmentCompletion(
"assignment_user_id",
TEST_STUDENT1_USER_ID
"evaluation_user_id",
TEST_TRAINER1_USER_ID
).then((ac) => {
expect(ac.completion_status).to.equal("EVALUATION_IN_PROGRESS");
expect(JSON.stringify(ac.completion_data)).to.include("Nicht so gut");
@ -187,8 +187,8 @@ describe("assignmentTrainer.cy.js", () => {
// load AssignmentCompletion from DB and check
cy.loadAssignmentCompletion(
"assignment_user_id",
TEST_STUDENT1_USER_ID
"evaluation_user_id",
TEST_TRAINER1_USER_ID
).then((ac) => {
expect(ac.completion_status).to.equal("EVALUATION_SUBMITTED");
expect(ac.evaluation_points).to.equal(17);
@ -237,8 +237,8 @@ describe("assignmentTrainer.cy.js", () => {
// load AssignmentCompletion from DB and check
cy.loadAssignmentCompletion(
"assignment_user_id",
TEST_STUDENT1_USER_ID
"evaluation_user_id",
TEST_TRAINER1_USER_ID
).then((ac) => {
console.log(ac.completion_status);
expect(ac.completion_status).to.equal("EVALUATION_IN_PROGRESS");
@ -323,8 +323,8 @@ describe("assignmentTrainer.cy.js", () => {
// load AssignmentCompletion from DB and check
cy.loadAssignmentCompletion(
"assignment_user_id",
TEST_STUDENT1_USER_ID
"evaluation_user_id",
TEST_TRAINER1_USER_ID
).then((ac) => {
expect(ac.completion_status).to.equal("EVALUATION_SUBMITTED");
expect(ac.evaluation_max_points).to.equal(0);

View File

@ -74,7 +74,7 @@ describe("dashboardSupervisor.cy.js", () => {
describe("feedback summary box", () => {
it("contains correct numbers", () => {
getDashboardStatistics("feedback.average").should("have.text", "3.3");
getDashboardStatistics("feedback.count").should("have.text", "3");
getDashboardStatistics("feedback.count").should("have.text", "6");
});
it("contains correct details link", () => {
clickOnDetailsLink("feedback");

View File

@ -5,153 +5,359 @@ describe("feedbackStudent.cy.js", () => {
beforeEach(() => {
cy.manageCommand("cypress_reset");
login("test-student1@example.com", "test");
cy.visit("/course/test-lehrgang/learn/fahrzeug/feedback");
});
it("can open feedback page", () => {
cy.testLearningContentTitle("Kursfeedback");
cy.testLearningContentSubtitle("Feedback");
describe("Feedback UK", () => {
beforeEach(() => {
cy.visit("/course/test-lehrgang/learn/fahrzeug/feedback");
});
it("can open feedback page", () => {
cy.testLearningContentTitle("Kursfeedback");
cy.testLearningContentSubtitle("Feedback");
});
it("can create feedback by giving answers to all steps", () => {
// initial wait for step 0 (or none with step==0) is required for pipelines
cy.url().should((url) => {
expect(url).to.match(/\/fahrzeug\/feedback(\?step=0)?$/);
});
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// fill feedback form
// step 1
cy.url().should("include", "step=1");
cy.get('[data-cy="question-1"]').should(
"contain",
"Zufriedenheit insgesamt"
);
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="radio-4"]').click();
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 2
cy.url().should("include", "step=2");
cy.get('[data-cy="question-2"]').should(
"contain",
"Zielerreichung insgesamt"
);
cy.get('[data-cy="next-step"]').should("be.disabled");
// the system should store after every step -> check stored data
cy.loadFeedbackResponse("feedback_user_id", TEST_STUDENT1_USER_ID).then(
(ac) => {
expect(ac.submitted).to.be.false;
expect(ac.data.satisfaction).to.equal(4);
expect(ac.data.instructor_competence).to.equal(null);
}
);
cy.get('[data-cy="radio-3"]').click();
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 3
cy.url().should("include", "step=3");
cy.get('[data-cy="question-3"]').should(
"contain",
"Wie beurteilst du deine Sicherheit bezüglichen den Themen nach dem Kurs?"
);
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="radio-80"]').click();
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 4
cy.url().should("include", "step=4");
cy.get('[data-cy="question-4"]').should(
"contain",
"Waren die Vorbereitungsaufträge klar und verständlich?"
);
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="radio-false"]').click();
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 5
cy.url().should("include", "step=5");
cy.get('[data-cy="question-5"]').should(
"contain",
"Wie beurteilst du die Themensicherheit und Fachkompetenz des Kursleiters/der Kursleiterin?"
);
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="radio-2"]').click();
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 6
cy.url().should("include", "step=6");
cy.get('[data-cy="question-6"]').should(
"contain",
"Wurden Fragen und Anregungen der Kursteilnehmenden ernst genommen und aufgegriffen?"
);
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="radio-1"]').click();
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 7
cy.url().should("include", "step=7");
cy.get('[data-cy="question-7"]').should(
"contain",
"Was möchtest du dem Kursleiter/der Kursleiterin sonst noch sagen?"
);
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="it-textarea-instructor_open_feedback"]').type(
"Der Kursleiter ist eigentlich ganz nett."
);
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 8
cy.url().should("include", "step=8");
cy.get('[data-cy="question-8"]').should(
"contain",
"Würdest du den Kurs weiterempfehlen?"
);
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="radio-true"]').click();
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 9
cy.url().should("include", "step=9");
cy.get('[data-cy="question-9"]').should(
"contain",
"Was hat dir besonders gut gefallen?"
);
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="it-textarea-course_positive_feedback"]').type(
"Ich bin zufrieden mit den meisten Dingen."
);
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 10
cy.url().should("include", "step=10");
cy.get('[data-cy="question-10"]').should(
"contain",
"Wo siehst du Verbesserungspotential?"
);
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="it-textarea-course_negative_feedback"]').type(
"Ich bin unzufrieden mit einigen Sachen."
);
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
cy.url().should("include", "step=11");
cy.get('[data-cy="sendFeedbackButton"]').click();
cy.get('[data-cy="complete-and-continue"]').click({ force: true });
// marked complete in circle
cy.url().should((url) => {
expect(url).to.match(/\/fahrzeug#lu-transfer?$/);
});
cy.reload();
cy.get(
'[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-feedback-checkbox"]'
).should("have.class", "cy-checked");
// reopening page should get directly to last step
cy.visit("/course/test-lehrgang/learn/fahrzeug/feedback");
cy.url().should("include", "step=11");
// check stored data
cy.loadFeedbackResponse("feedback_user_id", TEST_STUDENT1_USER_ID).then(
(ac) => {
expect(ac.submitted).to.be.true;
expect(ac.data).to.deep.equal({
course_negative_feedback: "Ich bin unzufrieden mit einigen Sachen.",
course_positive_feedback:
"Ich bin zufrieden mit den meisten Dingen.",
goal_attainment: 3,
instructor_competence: 2,
instructor_open_feedback:
"Der Kursleiter ist eigentlich ganz nett.",
instructor_respect: 1,
preparation_task_clarity: false,
proficiency: 80,
satisfaction: 4,
would_recommend: true,
feedback_type: "uk",
});
}
);
});
});
it("can create feedback by giving answers to all steps", () => {
// initial wait for step 0 (or none with step==0) is required for pipelines
cy.url().should((url) => {
expect(url).to.match(/\/fahrzeug\/feedback(\?step=0)?$/);
describe("Feedback VV", () => {
beforeEach(() => {
cy.visit("/course/test-lehrgang/learn/reisen/feedback");
});
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// fill feedback form
// step 1
cy.url().should("include", "step=1");
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="radio-4"]').click();
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 2
cy.url().should("include", "step=2");
cy.get('[data-cy="next-step"]').should("be.disabled");
// the system should store after every step -> check stored data
cy.loadFeedbackResponse("feedback_user_id", TEST_STUDENT1_USER_ID).then(
(ac) => {
expect(ac.submitted).to.be.false;
expect(ac.data.satisfaction).to.equal(4);
expect(ac.data.instructor_competence).to.equal(null);
}
);
cy.get('[data-cy="radio-3"]').click();
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 3
cy.url().should("include", "step=3");
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="radio-80"]').click();
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 4
cy.url().should("include", "step=4");
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="radio-false"]').click();
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 5
cy.url().should("include", "step=5");
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="radio-2"]').click();
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 6
cy.url().should("include", "step=6");
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="radio-1"]').click();
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 7
cy.url().should("include", "step=7");
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="it-textarea-instructor_open_feedback"]').type(
"Der Kursleiter ist eigentlich ganz nett."
);
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 8
cy.url().should("include", "step=8");
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="radio-true"]').click();
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 9
cy.url().should("include", "step=9");
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="it-textarea-course_positive_feedback"]').type(
"Ich bin zufrieden mit den meisten Dingen."
);
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 10
cy.url().should("include", "step=10");
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="it-textarea-course_negative_feedback"]').type(
"Ich bin unzufrieden mit einigen Sachen."
);
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
cy.url().should("include", "step=11");
cy.get('[data-cy="sendFeedbackButton"]').click();
cy.get('[data-cy="complete-and-continue"]').click({ force: true });
// marked complete in circle
cy.url().should((url) => {
expect(url).to.match(/\/fahrzeug#lu-transfer?$/);
it("can open feedback page", () => {
cy.testLearningContentTitle("Feedback");
cy.testLearningContentSubtitle("Feedback");
});
cy.reload();
cy.get(
'[data-cy="test-lehrgang-lp-circle-fahrzeug-lc-feedback-checkbox"]'
).should("have.class", "cy-checked");
// reopening page should get directly to last step
cy.visit("/course/test-lehrgang/learn/fahrzeug/feedback");
cy.url().should("include", "step=11");
it("can create feedback by giving answers to all steps", () => {
// initial wait for step 0 (or none with step==0) is required for pipelines
cy.url().should((url) => {
expect(url).to.match(/\/reisen\/feedback(\?step=0)?$/);
});
cy.get('[data-cy="introduction"]').contains(
"Wir bitten dich um dein Feedback. Es hilft uns, damit wir deine Lernerlebnisse verbessern können."
);
// check stored data
cy.loadFeedbackResponse("feedback_user_id", TEST_STUDENT1_USER_ID).then(
(ac) => {
expect(ac.submitted).to.be.true;
expect(ac.data).to.deep.equal({
course_negative_feedback: "Ich bin unzufrieden mit einigen Sachen.",
course_positive_feedback: "Ich bin zufrieden mit den meisten Dingen.",
goal_attainment: 3,
instructor_competence: 2,
instructor_open_feedback: "Der Kursleiter ist eigentlich ganz nett.",
instructor_respect: 1,
preparation_task_clarity: false,
proficiency: 80,
satisfaction: 4,
would_recommend: true,
});
}
);
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// fill feedback form
// step 1
cy.url().should("include", "step=1");
cy.get('[data-cy="question-1"]').should(
"contain",
"Zufriedenheit insgesamt"
);
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="radio-4"]').click();
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 2
cy.url().should("include", "step=2");
cy.get('[data-cy="question-2"]').should(
"contain",
"Zielerreichung insgesamt"
);
cy.get('[data-cy="next-step"]').should("be.disabled");
// the system should store after every step -> check stored data
cy.loadFeedbackResponse("feedback_user_id", TEST_STUDENT1_USER_ID).then(
(ac) => {
expect(ac.submitted).to.be.false;
expect(ac.data.satisfaction).to.equal(4);
expect(ac.data.course_positive_feedback).to.equal(null);
}
);
cy.get('[data-cy="radio-3"]').click();
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 3
cy.url().should("include", "step=3");
cy.get('[data-cy="question-3"]').should(
"contain",
"Wie beurteilst du deine Sicherheit bezüglichen den Themen nach dem Circle?"
);
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="radio-80"]').click();
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 4
cy.url().should("include", "step=4");
cy.get('[data-cy="question-4"]').should(
"contain",
"Waren die Praxisaufträge klar und verständlich?"
);
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="radio-false"]').click();
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 5
cy.url().should("include", "step=5");
cy.get('[data-cy="question-5"]').should(
"contain",
"Würdest du den Circle weiterempfehlen?"
);
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="radio-false"]').click();
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 6
cy.url().should("include", "step=6");
cy.get('[data-cy="question-6"]').should(
"contain",
"Was hat dir besonders gut gefallen?"
);
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="it-textarea-course_positive_feedback"]').type(
"Der Circle ist eigentlich ganz nett."
);
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
// step 7
cy.url().should("include", "step=7");
cy.get('[data-cy="question-7"]').should(
"contain",
"Wo siehst du Verbesserungspotential?"
);
cy.get('[data-cy="next-step"]').should("be.disabled");
cy.get('[data-cy="it-textarea-course_negative_feedback"]').type(
"Ich bin unzufrieden mit einigen Sachen."
);
cy.wait(200);
cy.learningContentMultiLayoutNextStep();
cy.wait(200);
cy.url().should("include", "step=8");
cy.get('[data-cy="sendFeedbackButton"]').click();
cy.get('[data-cy="complete-and-continue"]').click({ force: true });
// marked complete in circle
cy.url().should((url) => {
expect(url).to.match(/\/reisen#lu-transfer-reflexion-feedback?$/);
});
cy.reload();
cy.get(
'[data-cy="test-lehrgang-lp-circle-reisen-lc-feedback-checkbox"]'
).should("have.class", "cy-checked");
// reopening page should get directly to last step
cy.visit("/course/test-lehrgang/learn/reisen/feedback");
cy.url().should("include", "step=8");
// check stored data
cy.loadFeedbackResponse("feedback_user_id", TEST_STUDENT1_USER_ID).then(
(ac) => {
expect(ac.submitted).to.be.true;
expect(ac.data).to.deep.equal({
course_negative_feedback: "Ich bin unzufrieden mit einigen Sachen.",
course_positive_feedback: "Der Circle ist eigentlich ganz nett.",
goal_attainment: 3,
preparation_task_clarity: false,
proficiency: 80,
satisfaction: 4,
would_recommend: false,
feedback_type: "vv",
});
}
);
});
});
});

View File

@ -16,77 +16,216 @@ describe("feedbackTrainer.cy.js", () => {
cy.get('[data-cy="feedback-data-amount"]').should("contain", "0");
});
it("can open feedback results page with results", () => {
cy.manageCommand("cypress_reset --create-feedback-responses");
login("test-trainer1@example.com", "test");
cy.visit("/course/test-lehrgang/cockpit");
cy.get(
'[data-cy="show-feedback-btn-test-lehrgang-lp-circle-fahrzeug-lc-feedback"]'
).click();
describe("FeedbackUK", function () {
it("can open feedback results page with results", () => {
cy.manageCommand("cypress_reset --create-feedback-responses");
login("test-trainer1@example.com", "test");
cy.visit("/course/test-lehrgang/cockpit");
cy.get(
'[data-cy="show-feedback-btn-test-lehrgang-lp-circle-fahrzeug-lc-feedback"]'
).click();
cy.get('[data-cy="feedback-data-amount"]').should("contain", "3");
cy.get('[data-cy="feedback-data-amount"]').should("contain", "3");
cy.get('[data-cy="question-1"]')
.find('[data-cy="rating-scale-average"]')
.should("contain", "3.3");
// check titles of questions
cy.get('[data-cy="question-1"]').should(
"contain",
"Zufriedenheit insgesamt"
);
cy.get('[data-cy="question-2"]').should(
"contain",
"Zielerreichung insgesamt"
);
cy.get('[data-cy="question-3"]').should(
"contain",
"Wie beurteilst du deine Sicherheit bezüglichen den Themen nach dem Kurs?"
);
cy.get('[data-cy="question-4"]').should(
"contain",
"Waren die Vorbereitungsaufträge klar und verständlich?"
);
cy.get('[data-cy="question-5"]').should(
"contain",
"Wie beurteilst du die Themensicherheit und Fachkompetenz des Kursleiters/der Kursleiterin?"
);
cy.get('[data-cy="question-6"]').should(
"contain",
"Wurden Fragen und Anregungen der Kursteilnehmenden ernst genommen und aufgegriffen?"
);
cy.get('[data-cy="question-7"]').should(
"contain",
"Was möchtest du dem Kursleiter/der Kursleiterin sonst noch sagen?"
);
cy.get('[data-cy="question-8"]').should(
"contain",
"Würdest du den Kurs weiterempfehlen?"
);
cy.get('[data-cy="question-9"]').should(
"contain",
"Wo siehst du Verbesserungspotential?"
);
cy.get('[data-cy="question-10"]').should(
"contain",
"Was hat dir besonders gut gefallen?"
);
cy.get('[data-cy="question-2"]')
.find('[data-cy="rating-scale-average"]')
.should("contain", "3.0");
cy.get('[data-cy="question-1"]')
.find('[data-cy="rating-scale-average"]')
.should("contain", "3.3");
cy.get('[data-cy="question-3"]')
.find('[data-cy="percentage-value-40%"]')
.should("contain", "33.3");
cy.get('[data-cy="question-3"]')
.find('[data-cy="percentage-value-80%"]')
.should("contain", "33.3");
cy.get('[data-cy="question-3"]')
.find('[data-cy="percentage-value-100%"]')
.should("contain", "33.3");
cy.get('[data-cy="question-2"]')
.find('[data-cy="rating-scale-average"]')
.should("contain", "3.0");
cy.get('[data-cy="question-4"]')
.find('[data-cy="popover-yes"]')
.click()
.find('[data-cy="num-yes"]')
.should("contain", "3");
cy.get('[data-cy="question-4"]')
.find('[data-cy="popover-no"]')
.click()
.find('[data-cy="num-no"]')
.should("contain", "0");
cy.get('[data-cy="question-3"]')
.find('[data-cy="percentage-value-40%"]')
.should("contain", "33.3");
cy.get('[data-cy="question-3"]')
.find('[data-cy="percentage-value-80%"]')
.should("contain", "33.3");
cy.get('[data-cy="question-3"]')
.find('[data-cy="percentage-value-100%"]')
.should("contain", "33.3");
cy.get('[data-cy="question-5"]')
.find('[data-cy="rating-scale-average"]')
.should("contain", "2.7");
cy.get('[data-cy="question-4"]')
.find('[data-cy="popover-yes"]')
.click()
.find('[data-cy="num-yes"]')
.should("contain", "3");
cy.get('[data-cy="question-4"]')
.find('[data-cy="popover-no"]')
.click()
.find('[data-cy="num-no"]')
.should("contain", "0");
cy.get('[data-cy="question-6"]')
.find('[data-cy="rating-scale-average"]')
.should("contain", "3.0");
cy.get('[data-cy="question-5"]')
.find('[data-cy="rating-scale-average"]')
.should("contain", "2.7");
cy.get('[data-cy="question-7"]')
.should("contain", "Super Kurs!")
.should("contain", "Super, bin begeistert")
.should("contain", "Ok, entspricht den Erwartungen");
cy.get('[data-cy="question-6"]')
.find('[data-cy="rating-scale-average"]')
.should("contain", "3.0");
cy.get('[data-cy="question-8"]')
.find('[data-cy="popover-yes"]')
.click()
.find('[data-cy="num-yes"]')
.should("contain", "2");
cy.get('[data-cy="question-8"]')
.find('[data-cy="popover-no"]')
.click()
.find('[data-cy="num-no"]')
.should("contain", "1");
cy.get('[data-cy="question-7"]')
.should("contain", "Super Kurs!")
.should("contain", "Super, bin begeistert")
.should("contain", "Ok, entspricht den Erwartungen");
cy.get('[data-cy="question-9"]')
.should("contain", "Nichts Schlechtes")
.should("contain", "Es wäre praktisch, Zugang zu einer FAQ zu haben.")
.should("contain", "Mehr Videos wären schön.");
cy.get('[data-cy="question-8"]')
.find('[data-cy="popover-yes"]')
.click()
.find('[data-cy="num-yes"]')
.should("contain", "2");
cy.get('[data-cy="question-8"]')
.find('[data-cy="popover-no"]')
.click()
.find('[data-cy="num-no"]')
.should("contain", "1");
cy.get('[data-cy="question-10"]')
.should("contain", "Nur Gutes.")
.should("contain", "Das Beispiel mit der Katze fand ich sehr gut")
.should("contain", "Die Präsentation war super");
cy.get('[data-cy="question-9"]')
.should("contain", "Nichts Schlechtes")
.should("contain", "Es wäre praktisch, Zugang zu einer FAQ zu haben.")
.should("contain", "Mehr Videos wären schön.");
cy.get('[data-cy="question-10"]')
.should("contain", "Nur Gutes.")
.should("contain", "Das Beispiel mit der Katze fand ich sehr gut")
.should("contain", "Die Präsentation war super");
});
});
describe("FeedbackVV", function () {
it("can open feedback results page with results", () => {
cy.manageCommand("cypress_reset --create-feedback-responses");
login("test-trainer1@example.com", "test");
cy.visit("/course/test-lehrgang/cockpit");
cy.get('[data-cy="dropdown-select"]').click();
cy.get('[data-cy="dropdown-select-option-Reisen"]').click();
cy.get(
'[data-cy="show-feedback-btn-test-lehrgang-lp-circle-reisen-lc-feedback"]'
).click();
cy.get('[data-cy="feedback-data-amount"]').should("contain", "3");
// check titles of questions
cy.get('[data-cy="question-1"]').should(
"contain",
"Zufriedenheit insgesamt"
);
cy.get('[data-cy="question-2"]').should(
"contain",
"Zielerreichung insgesamt"
);
cy.get('[data-cy="question-3"]').should(
"contain",
"Wie beurteilst du deine Sicherheit bezüglichen den Themen nach dem Circle?"
);
cy.get('[data-cy="question-4"]').should(
"contain",
"Waren die Praxisaufträge klar und verständlich?"
);
cy.get('[data-cy="question-5"]').should(
"contain",
"Würdest du den Circle weiterempfehlen?"
);
cy.get('[data-cy="question-6"]').should(
"contain",
"Wo siehst du Verbesserungspotential?"
);
cy.get('[data-cy="question-7"]').should(
"contain",
"Was hat dir besonders gut gefallen?"
);
cy.get('[data-cy="question-1"]')
.find('[data-cy="rating-scale-average"]')
.should("contain", "3.3");
cy.get('[data-cy="question-2"]')
.find('[data-cy="rating-scale-average"]')
.should("contain", "3.0");
cy.get('[data-cy="question-3"]')
.find('[data-cy="percentage-value-40%"]')
.should("contain", "33.3");
cy.get('[data-cy="question-3"]')
.find('[data-cy="percentage-value-80%"]')
.should("contain", "33.3");
cy.get('[data-cy="question-3"]')
.find('[data-cy="percentage-value-100%"]')
.should("contain", "33.3");
cy.get('[data-cy="question-4"]')
.find('[data-cy="popover-yes"]')
.click()
.find('[data-cy="num-yes"]')
.should("contain", "3");
cy.get('[data-cy="question-4"]')
.find('[data-cy="popover-no"]')
.click()
.find('[data-cy="num-no"]')
.should("contain", "0");
cy.get('[data-cy="question-5"]')
.find('[data-cy="popover-yes"]')
.click()
.find('[data-cy="num-yes"]')
.should("contain", "2");
cy.get('[data-cy="question-5"]')
.find('[data-cy="popover-no"]')
.click()
.find('[data-cy="num-no"]')
.should("contain", "1");
cy.get('[data-cy="question-6"]')
.should("contain", "Nichts Schlechtes")
.should("contain", "Es wäre praktisch, Zugang zu einer FAQ zu haben.")
.should("contain", "Mehr Videos wären schön.");
cy.get('[data-cy="question-7"]')
.should("contain", "Nur Gutes.")
.should("contain", "Das Beispiel mit der Katze fand ich sehr gut")
.should("contain", "Die Präsentation war super");
});
});
});

View File

@ -61,6 +61,7 @@ Cypress.Commands.add("manageCommand", (command, preCommand = "") => {
"/Users/daniel/workspace/vbv_lernwelt/.direnv/python-3.10.6/bin",
"/Users/eliabieri/iterativ/vbv_lernwelt/.direnv/python-3.10.6/bin",
"/Users/christiancueni/workspace/vbv_lernwelt/.direnv/python-3.10.6/bin",
"/Users/renzo/workspace/vbv_lernwelt/.direnv/python-3.10.6/bin",
];
let bashCommand = `PATH=${pythonPaths.join(":")}:$PATH && ${execCommand}`;
return cy

Binary file not shown.

After

Width:  |  Height:  |  Size: 68 KiB

View File

@ -0,0 +1,73 @@
# Files handling
This document describes how files are handled in this appication.
# Types of files
static files: files that are not changed by the application, e.g. images, fonts, etc.¨
### content documents:
Files that belong to the content and are managed by the content editors in the CMS (pdf, excel, word, etc.)
### user documents:
Files that are uploaded by the users (pdf, etc.). Therefore not visible in the CMS.
Images are handled seprately from documents since images require additional processing (resizing, cropping, etc.).
Visible in the django admin.
### content images:
Images that belong to the content and are managed by the content editors in the CMS.
### user images:
Images that are uploaded by the users. Therefore not visible in the CMS. Visible in the django admin.
## Static files
These files are publicly served on S3.
## Content documents
These files are part of the content. Such as a pdf thas cointains additional information to a course.
These files are not publicly available. The content files are uploaded by the editors in the wagtail cms.
https://www.hacksoft.io/blog/direct-to-s3-file-upload-with-django
Django handles the permissions to these files. Via a view django checks if the user has permissions to access the file,
and gerates a temporary url that is valid for a limited time. Still the documents are served by django. This done for
usability reasons. The user sees the url mydomain.com/media/documents/<document-id> and not a url to S3. Therefore the
user can share the url with other users. (still they need to login and have the permissions to access the file)
The downside of this is that the django server processes these files. (could be circumvented by django-sendfile).
![](./assets/files-presign.png)
- These Files are handled stored as wagtail documents. As a model and the file itself is stored in S3.
### Frontend access to content documents
For the frontend django generates a fixed url per file /media/documents/<document-id>
When the frontend requests this file, django checks if the user has permissions to access the file.
If so, django generates a temporary url that is valid for a limited time. Then sends a redirect to the frontend.
In this waz the frontend does not need to know about the permissions. Content grapql can be cached if needed and urls
can be shared by the users.
content_documents
user_documents
public files
## User documents
- User uploaded files are stored in S3. but the permissions is handled by django. Same process as content files.
Same process as content files. But the url is /media/user-uploads/<file-id>
And the files are not managed by Wagtail. Due to another model, they are not visible to the user in the CMS.
## Content images
Content Images are served directly from S3. The permissions are handled by dja

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -18,8 +18,7 @@ def main():
from django.conf import settings
settings.DEBUG = True
from django.db import connection
from django.db import reset_queries
from django.db import connection, reset_queries
reset_queries()

View File

@ -12,16 +12,15 @@ os.environ.setdefault("IT_APP_ENVIRONMENT", "local")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.base")
django.setup()
from vbv_lernwelt.core.schema import Query
from vbv_lernwelt.core.models import User
from vbv_lernwelt.core.schema import Query
def main():
from django.conf import settings
settings.DEBUG = True
from django.db import connection
from django.db import reset_queries
from django.db import connection, reset_queries
reset_queries()

View File

@ -10,12 +10,12 @@ os.environ.setdefault("IT_APP_ENVIRONMENT", "local")
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings.base")
django.setup()
from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse
from vbv_lernwelt.notify.email.email_services import (
create_template_data_from_course_session_attendance_course,
EmailTemplate,
send_email,
create_template_data_from_course_session_attendance_course,
)
from vbv_lernwelt.course_session.models import CourseSessionAttendanceCourse
def main():

View File

@ -116,6 +116,7 @@ THIRD_PARTY_APPS = [
LOCAL_APPS = [
"vbv_lernwelt.core",
"vbv_lernwelt.media_files",
"vbv_lernwelt.sso",
"vbv_lernwelt.course",
"vbv_lernwelt.learnpath",
@ -213,23 +214,13 @@ STATICFILES_FINDERS = [
"django.contrib.staticfiles.finders.AppDirectoriesFinder",
]
USE_AWS = env("USE_AWS", False)
AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME", "")
AWS_ACCESS_KEY_ID = env("AWS_ACCESS_KEY_ID", "")
AWS_SECRET_ACCESS_KEY = env("AWS_SECRET_ACCESS_KEY", "")
AWS_S3_CUSTOM_DOMAIN = "%s.s3.amazonaws.com" % AWS_STORAGE_BUCKET_NAME
# MEDIA
# ------------------------------------------------------------------------------
# https://docs.djangoproject.com/en/dev/ref/settings/#media-root
MEDIA_ROOT = str(APPS_DIR / "media")
# https://docs.djangoproject.com/en/dev/ref/settings/#media-url
if USE_AWS:
# https://wagtail.org/blog/amazon-s3-for-media-files/
MEDIA_URL = "https://%s/" % AWS_S3_CUSTOM_DOMAIN
DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
else:
MEDIA_URL = "/server/media/"
MEDIA_URL = "/server/media/"
IT_SERVE_VUE = env.bool("IT_SERVE_VUE", DEBUG)
IT_SERVE_VUE_URL = env("IT_SERVE_VUE_URL", "http://localhost:5173")
@ -253,7 +244,19 @@ WAGTAIL_ENABLE_UPDATE_CHECK = False
WAGTAIL_ENABLE_WHATS_NEW_BANNER = False
WAGTAIL_CONTENT_LANGUAGES = LANGUAGES
WAGTAILDOCS_DOCUMENT_MODEL = "media_library.LibraryDocument"
WAGTAILDOCS_DOCUMENT_MODEL = "media_files.ContentDocument"
WAGTAILIMAGES_IMAGE_MODEL = "media_files.ContentImage"
# this setting makes that the document is served by django, and the url is the django url.
# https://docs.wagtail.org/en/stable/reference/settings.html#wagtaildocs-serve-method
# The file is served by django as streaming response. If it should be serverd by nginx, then install django sendfile
WAGTAILDOCS_SERVE_METHOD = "serve_view"
# WAGTAILDOCS_INLINE_CONTENT_TYPES = ['application/pdf', 'text/plain']
WAGTAILIMAGES_MAX_UPLOAD_SIZE = 20 * 1024 * 1024 # 20MB
# WAGTAILIMAGES_RENDITION_STORAGE = 'myapp.backends.MyCustomStorage'
WAGTAILADMIN_RICH_TEXT_EDITORS = {
"default": {
@ -646,7 +649,7 @@ NOTIFICATIONS_NOTIFICATION_MODEL = "notify.Notification"
SENDGRID_API_KEY = env("IT_SENDGRID_API_KEY", default="")
# S3 BUCKET CONFIGURATION
FILE_UPLOAD_STORAGE = env("FILE_UPLOAD_STORAGE", default="local") # local | s3
FILE_UPLOAD_STORAGE = env("FILE_UPLOAD_STORAGE", default="s3") # local | s3
if FILE_UPLOAD_STORAGE == "local":
FILE_MAX_SIZE = env.int("FILE_MAX_SIZE", default=5242880)
@ -655,18 +658,19 @@ if FILE_UPLOAD_STORAGE == "s3":
# Using django-storages
# https://django-storages.readthedocs.io/en/latest/backends/amazon-S3.html
DEFAULT_FILE_STORAGE = "storages.backends.s3boto3.S3Boto3Storage"
AWS_S3_ACCESS_KEY_ID = env("AWS_S3_ACCESS_KEY_ID")
AWS_S3_ACCESS_KEY_ID = env("AWS_S3_ACCESS_KEY_ID", default="AKIAZJLREPUVWNBTJ5VY")
AWS_S3_SECRET_ACCESS_KEY = env("AWS_S3_SECRET_ACCESS_KEY")
AWS_STORAGE_BUCKET_NAME = env("AWS_STORAGE_BUCKET_NAME")
AWS_S3_REGION_NAME = env("AWS_S3_REGION_NAME")
AWS_S3_REGION_NAME = env("AWS_S3_REGION_NAME", "eu-central-1")
AWS_S3_SIGNATURE_VERSION = env("AWS_S3_SIGNATURE_VERSION", default="s3v4")
AWS_STORAGE_BUCKET_NAME = env(
"AWS_STORAGE_BUCKET_NAME", default="myvbv-dev.iterativ.ch"
)
AWS_S3_FILE_OVERWRITE = env("AWS_S3_FILE_OVERWRITE", False)
FILE_MAX_SIZE = env.int("FILE_MAX_SIZE", default=20971520) # 20MB
# https://docs.aws.amazon.com/AmazonS3/latest/userguide/acl-overview.html#canned-acl
AWS_DEFAULT_ACL = env("AWS_DEFAULT_ACL", default="private")
AWS_PRESIGNED_EXPIRY = env.int("AWS_PRESIGNED_EXPIRY", default=300) # seconds
AWS_PRESIGNED_EXPIRY = env.int("AWS_PRESIGNED_EXPIRY", default=7200) # seconds
WHITENOISE_SKIP_COMPRESS_EXTENSIONS = (
"jpg",

View File

@ -1,6 +1,7 @@
# pylint: disable=unused-wildcard-import,wildcard-import,wrong-import-position
import os
os.environ["IT_APP_ENVIRONMENT"] = "local"
from .base import * # noqa
@ -8,6 +9,7 @@ from .base import * # noqa
# https://docs.djangoproject.com/en/dev/ref/settings/#test-runner
TEST_RUNNER = "django.test.runner.DiscoverRunner"
# Select faster password hasher during tests
# https://docs.djangoproject.com/en/dev/ref/settings/#password-hashers
PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
@ -15,16 +17,7 @@ PASSWORD_HASHERS = ["django.contrib.auth.hashers.MD5PasswordHasher"]
EMAIL_BACKEND = "django.core.mail.backends.locmem.EmailBackend"
WHITENOISE_MANIFEST_STRICT = False
# Dummy data
AWS_S3_ACCESS_KEY_ID = "SOMEKEY"
AWS_S3_SECRET_ACCESS_KEY = "SOMEACCESSKEY"
AWS_STORAGE_BUCKET_NAME = "myvbv-dev.iterativ.ch"
AWS_S3_REGION_NAME = "eu-central-1"
AWS_S3_SIGNATURE_VERSION = "s3v4"
FILE_MAX_SIZE = 20971520 # 20MB
AWS_DEFAULT_ACL = "private"
AWS_PRESIGNED_EXPIRY = 300
AWS_S3_FILE_OVERWRITE = True
class DisableMigrations(dict):
@ -36,8 +29,3 @@ class DisableMigrations(dict):
MIGRATION_MODULES = DisableMigrations()
# Select faster password hasher during tests
PASSWORD_HASHERS = [
"django.contrib.auth.hashers.MD5PasswordHasher",
]

View File

@ -2,6 +2,10 @@
import os
os.environ["IT_APP_ENVIRONMENT"] = "local"
os.environ["AWS_S3_SECRET_ACCESS_KEY"] = os.environ.get(
"AWS_S3_SECRET_ACCESS_KEY",
"!!!default_for_quieting_cypress_within_pycharm!!!",
)
from .base import * # noqa

View File

@ -57,7 +57,7 @@ from vbv_lernwelt.importer.views import (
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
from wagtail.documents import urls as media_library_urls
class SignedIntConverter(IntConverter):
@ -89,7 +89,7 @@ urlpatterns = [
# wagtail urls
path('server/cms/', include(wagtailadmin_urls)),
path('server/documents/', include(wagtaildocs_urls)),
path('server/documents/', include(media_library_urls)),
path('server/pages/', include(wagtail_urls)),
# core
@ -138,6 +138,7 @@ urlpatterns = [
name="request_assignment_completion_status"),
# documents
# TODO: remfactor to files app
path(r'api/core/document/start/', document_upload_start,
name='file_upload_start'),
path(r'api/core/document/<str:document_id>/', document_delete,

View File

@ -9,6 +9,7 @@ mypy # https://github.com/python/mypy
django-stubs # https://github.com/typeddjango/django-stubs
pytest # https://github.com/pytest-dev/pytest
pytest-sugar # https://github.com/Frozenball/pytest-sugar
pytest-xdist #
djangorestframework-stubs # https://github.com/typeddjango/djangorestframework-stubs

View File

@ -38,9 +38,7 @@ azure-core==1.29.1
azure-identity==1.14.0
# via -r requirements.in
azure-storage-blob==12.17.0
# via
# -r requirements.in
# django-storages
# via -r requirements.in
backcall==0.2.0
# via ipython
bcrypt==4.0.1
@ -176,7 +174,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-stubs==4.2.3
# via
@ -209,6 +207,8 @@ exceptiongroup==1.1.2
# via
# anyio
# pytest
execnet==2.0.2
# via pytest-xdist
executing==1.2.0
# via stack-data
factory-boy==3.3.0
@ -397,7 +397,9 @@ pyflakes==3.1.0
pygments==2.16.1
# via ipython
pyjwt[crypto]==2.8.0
# via msal
# via
# msal
# pyjwt
pylint==2.17.5
# via
# pylint-django
@ -415,10 +417,13 @@ pytest==7.4.0
# -r requirements-dev.in
# pytest-django
# pytest-sugar
# pytest-xdist
pytest-django==4.5.2
# via -r requirements-dev.in
pytest-sugar==0.9.7
# via -r requirements-dev.in
pytest-xdist==3.5.0
# via -r requirements-dev.in
python-dateutil==2.8.2
# via
# -r requirements.in
@ -616,7 +621,9 @@ wheel==0.41.1
whitenoise[brotli]==6.5.0
# via -r requirements.in
willow[heif]==1.6.1
# via wagtail
# via
# wagtail
# willow
wrapt==1.15.0
# via astroid

View File

@ -1,4 +1,6 @@
#!/bin/bash
cd "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
pytest --junitxml=../test-reports/coverage.xml
# limit test to 6 parallel processes, otherwise ratelimit of s3 could be hit
pytest --numprocesses auto --maxprocesses=6 --junitxml=../test-reports/coverage.xml

View File

@ -3,7 +3,7 @@
set -e
cd "$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )"
coverage run -m pytest --junitxml=../test-reports/coverage.xml $1
coverage run -m pytest --numprocesses auto --maxprocesses=6 --junitxml=../test-reports/coverage.xml $1
coverage_python=`coverage report -m | tail -n1 | awk '{print $4}'`
commit=`git rev-parse HEAD`

View File

@ -20,6 +20,7 @@ from vbv_lernwelt.course.consts import (
COURSE_VERSICHERUNGSVERMITTLERIN_ID,
)
from vbv_lernwelt.course.models import CoursePage
from vbv_lernwelt.media_files.models import ContentDocument
from wagtail.blocks import StreamValue
from wagtail.blocks.list_block import ListBlock, ListValue
from wagtail.rich_text import RichText
@ -39,6 +40,7 @@ def create_uk_fahrzeug_casework(course_id=COURSE_UK, competence_certificate=None
needs_expert_evaluation=True,
competence_certificate=competence_certificate,
effort_required="ca. 5 Stunden",
solution_sample=ContentDocument.objects.get(title="Musterlösung Fahrzeug"),
intro_text=replace_whitespace(
"""
<h3>Ausgangslage</h3>

View File

@ -9,6 +9,7 @@ from vbv_lernwelt.course.graphql.interfaces import CoursePageInterface
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.iam.permissions import can_evaluate_assignments, has_course_access
from vbv_lernwelt.learnpath.graphql.types import LearningContentInterface
from vbv_lernwelt.media_files.graphql.types import ContentDocumentObjectType
class AssignmentCompletionObjectType(DjangoObjectType):
@ -52,6 +53,7 @@ class AssignmentObjectType(DjangoObjectType):
learning_content_page_id=graphene.ID(required=False),
assignment_user_id=graphene.UUID(required=False),
)
solution_sample = graphene.Field(ContentDocumentObjectType)
class Meta:
model = Assignment
@ -67,6 +69,9 @@ class AssignmentObjectType(DjangoObjectType):
"competence_certificate",
)
def resolve_solution_sample(self, info):
return self.solution_sample
def resolve_max_points(self, info):
return self.get_max_points()

View File

@ -0,0 +1,26 @@
# Generated by Django 3.2.20 on 2023-12-05 16:15
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("media_files", "0001_initial"),
("assignment", "0010_assignmentcompletion_edoniq_extended_time_flag"),
]
operations = [
migrations.AddField(
model_name="assignment",
name="solution_sample",
field=models.ForeignKey(
blank=True,
help_text="Musterlösung",
null=True,
on_delete=django.db.models.deletion.SET_NULL,
related_name="+",
to="media_files.contentdocument",
),
),
]

View File

@ -200,12 +200,22 @@ class Assignment(CourseBasePage):
help_text="Beurteilungsschritte",
)
solution_sample = models.ForeignKey(
"media_files.ContentDocument",
null=True,
blank=True,
on_delete=models.SET_NULL,
related_name="+",
help_text="Musterlösung",
)
content_panels = Page.content_panels + [
FieldPanel("assignment_type"),
FieldPanel("needs_expert_evaluation"),
PageChooserPanel("competence_certificate", "competence.CompetenceCertificate"),
FieldPanel("intro_text"),
FieldPanel("effort_required"),
FieldPanel("solution_sample"),
FieldPanel("performance_objectives"),
FieldPanel("tasks"),
FieldPanel("evaluation_description"),

View File

@ -106,14 +106,14 @@ class AttendanceCourseUserMutationTestCase(GraphQLTestCase):
)
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},
}
},
self.maxDiff = None
self.assertEqual(task_data["user_data"]["fileId"], file_id)
self.assertEqual(task_data["user_data"]["fileInfo"]["id"], file_id)
self.assertEqual(task_data["user_data"]["fileInfo"]["name"], "file.txt")
self.assertTrue(
task_data["user_data"]["fileInfo"]["url"].startswith(
"https://s3.eu-central-1.amazonaws.com/myvbv-dev.iterativ.ch"
)
)
# check DB data
@ -194,31 +194,31 @@ class AttendanceCourseUserMutationTestCase(GraphQLTestCase):
# check notification
self.assertEqual(Notification.objects.count(), 1)
notification = Notification.objects.first()
self.assertEquals(
self.assertEqual(
"Test Student1 hat die geleitete Fallarbeit «Überprüfen einer Motorfahrzeugs-Versicherungspolice» abgegeben.",
notification.verb,
)
self.assertEquals(
self.assertEqual(
"test-trainer1@example.com",
notification.recipient.email,
)
self.assertEquals(
self.assertEqual(
"test-student1@example.com",
notification.actor.email,
)
self.assertEquals(
self.assertEqual(
"USER_INTERACTION",
notification.notification_category,
)
self.assertEquals(
self.assertEqual(
"CASEWORK_SUBMITTED",
notification.notification_trigger,
)
self.assertEquals(
self.assertEqual(
notification.action_object,
db_entry,
)
self.assertEquals(
self.assertEqual(
notification.course_session,
self.course_session,
)
@ -422,35 +422,35 @@ class AttendanceCourseUserMutationTestCase(GraphQLTestCase):
# check notification
self.assertEqual(Notification.objects.count(), 1)
notification = Notification.objects.first()
self.assertEquals(
self.assertEqual(
"Test Trainer1 hat die geleitete Fallarbeit «Überprüfen einer Motorfahrzeugs-Versicherungspolice» bewertet.",
notification.verb,
)
self.assertEquals(
self.assertEqual(
"test-student1@example.com",
notification.recipient.email,
)
self.assertEquals(
self.assertEqual(
"test-trainer1@example.com",
notification.actor.email,
)
self.assertEquals(
self.assertEqual(
"USER_INTERACTION",
notification.notification_category,
)
self.assertEquals(
self.assertEqual(
"CASEWORK_EVALUATED",
notification.notification_trigger,
)
self.assertEquals(
self.assertEqual(
notification.action_object,
db_entry,
)
self.assertEquals(
self.assertEqual(
notification.course_session,
self.course_session,
)
self.assertEquals(
self.assertEqual(
notification.target_url,
"/course/test-lehrgang/learn/fahrzeug/überprüfen-einer-motorfahrzeug-versicherungspolice",
)

View File

@ -32,7 +32,8 @@ from vbv_lernwelt.course_session.services.attendance import AttendanceUserStatus
from vbv_lernwelt.feedback.models import FeedbackResponse
from vbv_lernwelt.learnpath.models import (
LearningContentAttendanceCourse,
LearningContentFeedback,
LearningContentFeedbackUK,
LearningContentFeedbackVV,
)
from vbv_lernwelt.notify.models import Notification
@ -155,7 +156,9 @@ def command(
if create_feedback_responses:
print("create_feedback_responses")
course_session = CourseSession.objects.get(id=TEST_COURSE_SESSION_BERN_ID)
learning_content_feedback_page = LearningContentFeedback.objects.get(
# feedback fahrzeug
learning_content_feedback_page = LearningContentFeedbackUK.objects.get(
slug="test-lehrgang-lp-circle-fahrzeug-lc-feedback"
)
create_feedback_response_data(
@ -174,6 +177,7 @@ def command(
"would_recommend": True,
"course_negative_feedback": "Nichts Schlechtes",
"course_positive_feedback": "Nur Gutes.",
"feedback_type": "uk",
},
)
@ -193,6 +197,7 @@ def command(
"would_recommend": True,
"course_negative_feedback": "Es wäre praktisch, Zugang zu einer FAQ zu haben.",
"course_positive_feedback": "Das Beispiel mit der Katze fand ich sehr gut veranschaulicht!",
"feedback_type": "uk",
},
)
@ -212,6 +217,62 @@ def command(
"would_recommend": False,
"course_negative_feedback": "Mehr Videos wären schön.",
"course_positive_feedback": "Die Präsentation war super",
"feedback_type": "uk",
},
)
# feedback reisen
learning_content_feedback_page = LearningContentFeedbackVV.objects.get(
slug="test-lehrgang-lp-circle-reisen-lc-feedback"
)
create_feedback_response_data(
feedback_user=User.objects.get(id=TEST_STUDENT1_USER_ID),
course_session=course_session,
learning_content_feedback_page=learning_content_feedback_page,
submitted=True,
feedback_data={
"satisfaction": 4,
"goal_attainment": 3,
"proficiency": 80,
"preparation_task_clarity": True,
"would_recommend": True,
"course_negative_feedback": "Nichts Schlechtes",
"course_positive_feedback": "Nur Gutes.",
"feedback_type": "vv",
},
)
create_feedback_response_data(
feedback_user=User.objects.get(id=TEST_STUDENT2_USER_ID),
course_session=course_session,
learning_content_feedback_page=learning_content_feedback_page,
submitted=True,
feedback_data={
"satisfaction": 4,
"goal_attainment": 4,
"proficiency": 100,
"preparation_task_clarity": True,
"would_recommend": True,
"course_negative_feedback": "Es wäre praktisch, Zugang zu einer FAQ zu haben.",
"course_positive_feedback": "Das Beispiel mit der Katze fand ich sehr gut veranschaulicht!",
"feedback_type": "vv",
},
)
create_feedback_response_data(
feedback_user=User.objects.get(id=TEST_STUDENT3_USER_ID),
course_session=course_session,
learning_content_feedback_page=learning_content_feedback_page,
submitted=True,
feedback_data={
"satisfaction": 2,
"goal_attainment": 2,
"proficiency": 40,
"preparation_task_clarity": True,
"would_recommend": False,
"course_negative_feedback": "Mehr Videos wären schön.",
"course_positive_feedback": "Die Präsentation war super",
"feedback_type": "vv",
},
)

View File

@ -70,7 +70,8 @@ from vbv_lernwelt.learnpath.tests.learning_path_factories import (
LearningContentAssignmentFactory,
LearningContentAttendanceCourseFactory,
LearningContentEdoniqTestFactory,
LearningContentFeedbackFactory,
LearningContentFeedbackUKFactory,
LearningContentFeedbackVVFactory,
LearningContentKnowledgeAssessmentFactory,
LearningContentLearningModuleFactory,
LearningContentMediaLibraryFactory,
@ -82,6 +83,12 @@ from vbv_lernwelt.learnpath.tests.learning_path_factories import (
LearningUnitFactory,
TopicFactory,
)
from vbv_lernwelt.media_files.create_default_documents import (
create_default_collections,
create_default_content_documents,
)
from vbv_lernwelt.media_files.create_default_images import create_default_images
from vbv_lernwelt.media_files.models import ContentDocument, ContentImage, UserImage
from vbv_lernwelt.media_library.tests.media_library_factories import (
MediaLibraryCategoryPageFactory,
MediaLibraryContentPageFactory,
@ -92,6 +99,11 @@ from vbv_lernwelt.media_library.tests.media_library_factories import (
def create_test_course(include_uk=True, include_vv=True, with_sessions=False):
# create_locales_for_wagtail()
create_default_collections()
create_default_content_documents()
if UserImage.objects.count() == 0 and ContentImage.objects.count() == 0:
create_default_images()
course = create_test_course_with_categories()
competence_certificate = create_test_competence_navi()
@ -360,6 +372,7 @@ def create_feedback_response_data(
"would_recommend": True,
"course_negative_feedback": "Nichts Schlechtes",
"course_positive_feedback": "Nur Gutes.",
"feedback_type": "uk",
}
return update_feedback_response(
@ -523,6 +536,14 @@ damit du erfolgreich mit deinem Lernpfad (durch-)starten kannst.
slug__startswith=f"test-lehrgang-assignment-reflexion"
),
),
assignment = Assignment.objects.get(
slug__startswith="test-lehrgang-assignment-überprüfen-einer-motorfahrzeugs"
)
assignment.solution_sample = ContentDocument.objects.get(
title="Musterlösung Fahrzeug"
)
assignment.save()
LearningContentAssignmentFactory(
title="Überprüfen einer Motorfahrzeug-Versicherungspolice",
parent=circle,
@ -530,7 +551,8 @@ damit du erfolgreich mit deinem Lernpfad (durch-)starten kannst.
slug__startswith="test-lehrgang-assignment-überprüfen-einer-motorfahrzeugs"
),
)
LearningContentFeedbackFactory(
LearningContentFeedbackUKFactory(
title="Feedback",
parent=circle,
)
@ -614,7 +636,8 @@ def create_test_circle_reisen(lp):
title="Reflexion",
parent=parent,
)
LearningContentFeedbackFactory(
LearningContentFeedbackVVFactory(
title="Feedback",
parent=parent,
)

View File

@ -22,7 +22,7 @@ from vbv_lernwelt.learnpath.tests.learning_path_factories import (
LearningContentAttendanceCourseFactory,
LearningContentDocumentListFactory,
LearningContentEdoniqTestFactory,
LearningContentFeedbackFactory,
LearningContentFeedbackUKFactory,
LearningContentMediaLibraryFactory,
LearningContentPlaceholderFactory,
LearningPathFactory,
@ -30,6 +30,11 @@ from vbv_lernwelt.learnpath.tests.learning_path_factories import (
LearningUnitFactory,
TopicFactory,
)
from vbv_lernwelt.media_files.create_default_documents import (
create_default_collections,
create_default_content_documents,
)
from vbv_lernwelt.media_files.create_default_images import create_default_images
from vbv_lernwelt.media_library.tests.media_library_factories import (
LearnMediaBlockFactory,
)
@ -40,6 +45,7 @@ def create_uk_learning_path(course_id=COURSE_UK, user=None, skip_locales=True):
user = User.objects.get(username="info@iterativ.ch")
course_page = CoursePage.objects.get(course_id=course_id)
lp = LearningPathFactory(
title="Lernpfad",
parent=course_page,
@ -254,7 +260,7 @@ damit du erfolgreich mit deinem Lernpfad (durch-)starten kannst.
title="Unterlagen für den Unterricht",
parent=circle,
)
LearningContentFeedbackFactory(
LearningContentFeedbackUKFactory(
parent=circle,
)
LearningSequenceFactory(title="Transfer", parent=circle, icon="it-icon-ls-end")
@ -364,7 +370,7 @@ In diesem Circle erfährst du wie die überbetrieblichen Kurse aufgebaut sind. Z
# test_url="https://exam.vbv-afa.ch/e-tutor/v4/user/course/pre_course_object?aid=1689096897473,2147466097",
# )
LearningUnitFactory(title="Feedback", title_hidden=True, parent=circle)
LearningContentFeedbackFactory(
LearningContentFeedbackUKFactory(
parent=circle,
)
LearningSequenceFactory(title="Transfer", parent=circle, icon="it-icon-ls-end")
@ -479,7 +485,7 @@ Dans ce cercle, tu apprendras comment les cours interentreprises sont structuré
test_url="https://exam.vbv-afa.ch/e-tutor/v4/user/course/pre_course_object?aid=1689096897473,2147466097",
)
LearningUnitFactory(title="Feedback", title_hidden=True, parent=circle)
LearningContentFeedbackFactory(
LearningContentFeedbackUKFactory(
parent=circle,
)
LearningSequenceFactory(title="Transfert", parent=circle, icon="it-icon-ls-end")
@ -594,7 +600,7 @@ In questo Circle imparerai come sono strutturati i corsi interaziendali. Imparer
test_url="https://exam.vbv-afa.ch/e-tutor/v4/user/course/pre_course_object?aid=1689096897473,2147466097",
)
LearningUnitFactory(title="Feedback", title_hidden=True, parent=circle)
LearningContentFeedbackFactory(
LearningContentFeedbackUKFactory(
parent=circle,
)
LearningSequenceFactory(title="Trasferimento", parent=circle, icon="it-icon-ls-end")
@ -699,7 +705,7 @@ In diesem Circle lernst du die wichtigsten Grundlagen bezüglich Versicherungswi
],
)
LearningUnitFactory(title="Feedback", title_hidden=True, parent=circle)
LearningContentFeedbackFactory(
LearningContentFeedbackUKFactory(
parent=circle,
)
LearningSequenceFactory(title="Transfer", parent=circle, icon="it-icon-ls-end")
@ -809,7 +815,7 @@ Dans ce cercle, tu apprends les bases les plus importantes en matière d'assuran
],
)
LearningUnitFactory(title="Feedback", title_hidden=True, parent=circle)
LearningContentFeedbackFactory(
LearningContentFeedbackUKFactory(
parent=circle,
)
LearningSequenceFactory(title="Transfert", parent=circle, icon="it-icon-ls-end")
@ -918,7 +924,7 @@ In questo Circle imparerai le basi più importanti del settore assicurativo e de
],
)
LearningUnitFactory(title="Feedback", title_hidden=True, parent=circle)
LearningContentFeedbackFactory(
LearningContentFeedbackUKFactory(
parent=circle,
)
LearningSequenceFactory(title="Trasferimento", parent=circle, icon="it-icon-ls-end")
@ -1058,7 +1064,7 @@ def create_uk_circle_fahrzeug(lp, title="Fahrzeug"):
],
)
LearningUnitFactory(title="Feedback", title_hidden=True, parent=circle)
LearningContentFeedbackFactory(
LearningContentFeedbackUKFactory(
parent=circle,
)
@ -1192,7 +1198,7 @@ def create_uk_fr_circle_fahrzeug(lp, title="Véhicule"):
],
)
LearningUnitFactory(title="Feedback", title_hidden=True, parent=circle)
LearningContentFeedbackFactory(
LearningContentFeedbackUKFactory(
parent=circle,
)
@ -1330,7 +1336,7 @@ def create_uk_it_circle_fahrzeug(lp, title="Veicolo"):
],
)
LearningUnitFactory(title="Feedback", title_hidden=True, parent=circle)
LearningContentFeedbackFactory(
LearningContentFeedbackUKFactory(
parent=circle,
)

View File

@ -10,7 +10,8 @@ from vbv_lernwelt.learnpath.graphql.types import (
LearningContentAttendanceCourseObjectType,
LearningContentDocumentListObjectType,
LearningContentEdoniqTestObjectType,
LearningContentFeedbackObjectType,
LearningContentFeedbackUKObjectType,
LearningContentFeedbackVVObjectType,
LearningContentKnowledgeAssessmentObjectType,
LearningContentLearningModuleObjectType,
LearningContentMediaLibraryObjectType,
@ -50,7 +51,8 @@ class CourseQuery(graphene.ObjectType):
learning_content_attendance_course = graphene.Field(
LearningContentAttendanceCourseObjectType
)
learning_content_feedback = graphene.Field(LearningContentFeedbackObjectType)
learning_content_feedback_uk = graphene.Field(LearningContentFeedbackUKObjectType)
learning_content_feedback_vv = graphene.Field(LearningContentFeedbackVVObjectType)
learning_content_learning_module = graphene.Field(
LearningContentLearningModuleObjectType
)

View File

@ -100,6 +100,12 @@ from vbv_lernwelt.learnpath.models import (
LearningContentAssignment,
LearningContentAttendanceCourse,
)
from vbv_lernwelt.media_files.create_default_documents import (
create_default_collections,
create_default_content_documents,
create_default_user_documents,
)
from vbv_lernwelt.media_files.create_default_images import create_default_images
from vbv_lernwelt.media_library.create_default_media_library import (
create_default_media_library,
)
@ -128,6 +134,11 @@ ADMIN_EMAILS = ["info@iterativ.ch", "admin"]
def command(course):
print("Creating default courses", course)
create_default_collections()
create_default_content_documents()
create_default_user_documents()
create_default_images()
if COURSE_VERSICHERUNGSVERMITTLERIN_ID in course:
create_versicherungsvermittlerin_course()
@ -285,6 +296,9 @@ def create_course_uk_de(course_id=COURSE_UK, lang="de"):
create_uk_competence_profile(course_id=course_id)
create_default_media_library(course_id=course_id)
create_default_collections()
create_default_content_documents()
def create_course_uk_de_course_sessions():
course = Course.objects.get(id=COURSE_UK)

View File

@ -148,8 +148,8 @@ class DashboardQuery(graphene.ObjectType):
assignment=ProgressDashboardAssignmentType( # noqa
_id=course_id, # noqa
total_count=len(evaluation_results), # noqa
points_max_count=points_max_count, # noqa
points_achieved_count=points_achieved_count, # noqa
points_max_count=int(points_max_count), # noqa
points_achieved_count=int(points_achieved_count), # noqa
),
)

View File

@ -1,6 +1,11 @@
from django.contrib import admin
from wagtail.models import Page
from vbv_lernwelt.course_session.models import (
CourseSessionAssignment,
CourseSessionAttendanceCourse,
CourseSessionEdoniqTest,
)
from vbv_lernwelt.duedate.models import DueDate
from vbv_lernwelt.learnpath.models import (
Circle,
@ -9,7 +14,16 @@ from vbv_lernwelt.learnpath.models import (
)
# Register your models here.
@admin.action(description="Re-sync URLs from LearningContent")
def sync_wagtail_due_date_url(modeladmin, request, queryset):
for assignment in CourseSessionAssignment.objects.all():
assignment.save()
for edoniq_test in CourseSessionEdoniqTest.objects.all():
edoniq_test.save()
for attendance in CourseSessionAttendanceCourse.objects.all():
attendance.save()
@admin.register(DueDate)
class DueDateAdmin(admin.ModelAdmin):
date_hierarchy = "start"
@ -23,6 +37,7 @@ class DueDateAdmin(admin.ModelAdmin):
]
list_filter = ["course_session__course", "course_session"]
readonly_fields = ["course_session", "page"]
actions = [sync_wagtail_due_date_url]
def get_readonly_fields(self, request, obj=None):
default_readonly = super(DueDateAdmin, self).get_readonly_fields(request, obj)

View File

@ -34,6 +34,7 @@ class FeedbackResponseFactory(DjangoModelFactory):
"Das Beispiel mit der Katze fand ich sehr gut veranschaulicht!",
]
),
"feedback_type": FuzzyChoice(["uk", "vv"]),
}
)

View File

@ -7,10 +7,16 @@ from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.feedback.graphql.types import (
FeedbackResponseObjectType as FeedbackResponseType,
)
from vbv_lernwelt.feedback.serializers import CourseFeedbackSerializer
from vbv_lernwelt.feedback.serializers import (
CourseFeedbackSerializerUK,
CourseFeedbackSerializerVV,
)
from vbv_lernwelt.feedback.services import update_feedback_response
from vbv_lernwelt.iam.permissions import has_course_session_access
from vbv_lernwelt.learnpath.models import LearningContentFeedback
from vbv_lernwelt.learnpath.models import (
LearningContentFeedbackUK,
LearningContentFeedbackVV,
)
logger = structlog.get_logger(__name__)
@ -25,6 +31,7 @@ class SendFeedbackMutation(graphene.Mutation):
class Arguments:
course_session_id = graphene.ID(required=True)
learning_content_page_id = graphene.ID(required=True)
learning_content_type = graphene.String(required=True)
data = GenericScalar()
submitted = graphene.Boolean(required=False, default_value=False)
@ -35,11 +42,29 @@ class SendFeedbackMutation(graphene.Mutation):
info,
course_session_id,
learning_content_page_id,
learning_content_type,
data,
submitted,
):
feedback_user_id = info.context.user.id
learning_content = LearningContentFeedback.objects.get(
if learning_content_type == "learnpath.LearningContentFeedbackVV":
learningContentFeedbackModel = LearningContentFeedbackVV
serializerClass = CourseFeedbackSerializerVV
data["feedback_type"] = "vv"
elif learning_content_type == "learnpath.LearningContentFeedbackUK":
learningContentFeedbackModel = LearningContentFeedbackUK
serializerClass = CourseFeedbackSerializerUK
data["feedback_type"] = "uk"
else:
errors = [
ErrorType(
field="learningContentType", messages="Invalid learningContentType"
)
]
return SendFeedbackMutation(errors=errors)
learning_content = learningContentFeedbackModel.objects.get(
id=learning_content_page_id
)
circle = learning_content.get_circle()
@ -65,7 +90,7 @@ class SendFeedbackMutation(graphene.Mutation):
course_session_id=course_session_id,
)
serializer = CourseFeedbackSerializer(data=data)
serializer = serializerClass(data=data)
if not serializer.is_valid():
logger.error(

View File

@ -0,0 +1,22 @@
# Generated by Django 3.2.20 on 2023-12-07 14:01
from django.db import migrations
def add_field_to_json(apps, _schema_editor):
FeedbackResponse = apps.get_model("feedback", "FeedbackResponse")
for instance in FeedbackResponse.objects.all():
if instance.data is None:
instance.data = {}
instance.data["feedback_type"] = "uk" # Set the default value
instance.save()
class Migration(migrations.Migration):
dependencies = [
("feedback", "0006_auto_20230922_1131"),
]
operations = [
migrations.RunPython(add_field_to_json),
]

View File

@ -5,6 +5,11 @@ from vbv_lernwelt.feedback.models import FeedbackResponse
logger = structlog.get_logger(__name__)
FEEDBACK_TYPES = (
("uk", "Feedback UK"),
("vv", "Feedback VV"),
)
class FeedbackIntegerField(serializers.IntegerField):
def __init__(self, **kwargs):
@ -13,7 +18,8 @@ class FeedbackIntegerField(serializers.IntegerField):
)
class CourseFeedbackSerializer(serializers.Serializer):
class CourseFeedbackSerializerUK(serializers.Serializer):
feedback_type = serializers.ChoiceField(choices=FEEDBACK_TYPES)
satisfaction = FeedbackIntegerField()
goal_attainment = FeedbackIntegerField()
proficiency = serializers.IntegerField(required=False, allow_null=True)
@ -33,6 +39,22 @@ class CourseFeedbackSerializer(serializers.Serializer):
)
class CourseFeedbackSerializerVV(serializers.Serializer):
feedback_type = serializers.ChoiceField(choices=FEEDBACK_TYPES)
satisfaction = FeedbackIntegerField()
goal_attainment = FeedbackIntegerField()
proficiency = serializers.IntegerField(required=False, allow_null=True)
preparation_task_clarity = serializers.BooleanField(required=False, allow_null=True)
materials_rating = FeedbackIntegerField()
would_recommend = serializers.BooleanField(required=False, allow_null=True)
course_positive_feedback = serializers.CharField(
required=False, allow_null=True, allow_blank=True
)
course_negative_feedback = serializers.CharField(
required=False, allow_null=True, allow_blank=True
)
class CypressFeedbackResponseSerializer(serializers.ModelSerializer):
class Meta:
model = FeedbackResponse

View File

@ -1,10 +1,15 @@
from typing import Union
import structlog
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.models import CourseCompletionStatus, CourseSession
from vbv_lernwelt.course.services import mark_course_completion
from vbv_lernwelt.feedback.models import FeedbackResponse
from vbv_lernwelt.learnpath.models import LearningContentFeedback
from vbv_lernwelt.learnpath.models import (
LearningContentFeedbackUK,
LearningContentFeedbackVV,
)
logger = structlog.get_logger(__name__)
@ -12,7 +17,9 @@ logger = structlog.get_logger(__name__)
def update_feedback_response(
feedback_user: User,
course_session: CourseSession,
learning_content_feedback_page: LearningContentFeedback,
learning_content_feedback_page: Union[
LearningContentFeedbackUK, LearningContentFeedbackVV
],
submitted: bool,
validated_data: dict,
):
@ -26,18 +33,7 @@ def update_feedback_response(
original_data = feedback_response.data
updated_data = validated_data
initial_data = {
"satisfaction": None,
"goal_attainment": None,
"proficiency": None,
"preparation_task_clarity": None,
"instructor_competence": None,
"instructor_respect": None,
"instructor_open_feedback": "",
"would_recommend": None,
"course_negative_feedback": "",
"course_positive_feedback": "",
}
initial_data = initial_data_for_feedback_page(learning_content_feedback_page)
merged_data = initial_data | {
key: updated_data[key]
@ -71,3 +67,36 @@ def update_feedback_response(
)
return feedback_response
def initial_data_for_feedback_page(
learning_content_feedback_page: Union[
LearningContentFeedbackUK, LearningContentFeedbackVV
]
):
if hasattr(learning_content_feedback_page, "learningcontentfeedbackuk"):
return {
"satisfaction": None,
"goal_attainment": None,
"proficiency": None,
"preparation_task_clarity": None,
"instructor_competence": None,
"instructor_respect": None,
"instructor_open_feedback": "",
"would_recommend": None,
"course_negative_feedback": "",
"course_positive_feedback": "",
"feedback_type": "uk",
}
if hasattr(learning_content_feedback_page, "learningcontentfeedbackvv"):
return {
"satisfaction": None,
"goal_attainment": None,
"proficiency": None,
"preparation_task_clarity": None,
"would_recommend": None,
"course_negative_feedback": "",
"course_positive_feedback": "",
"feedback_type": "vv",
}
return {}

View File

@ -114,6 +114,7 @@ class FeedbackRestApiTestCase(FeedbackBaseTestCase):
"course_negative_feedback": self.feedback_data[
"course_negative_feedback"
][i],
"feedback_type": "uk",
},
feedback_user=self.feedback_users[i],
submitted=True,
@ -129,6 +130,7 @@ class FeedbackRestApiTestCase(FeedbackBaseTestCase):
expected = {
"amount": 3,
"questions": self.feedback_data,
"feedbackType": "uk",
}
print(response.data)

View File

@ -0,0 +1,91 @@
import json
from graphene_django.utils.testing import GraphQLTestCase
from vbv_lernwelt.core.create_default_users import create_default_users
from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.consts import COURSE_TEST_ID
from vbv_lernwelt.course.creators.test_course import create_test_course
from vbv_lernwelt.course.models import CourseSession
from vbv_lernwelt.feedback.models import FeedbackResponse
from vbv_lernwelt.learnpath.models import LearningContentFeedbackUK
class FeedbackMutationTestCase(GraphQLTestCase):
GRAPHQL_URL = "/server/graphql/"
def setUp(self):
create_default_users()
create_test_course(include_vv=False, with_sessions=True)
self.course_session = CourseSession.objects.get(title="Test Bern 2022 a")
self.learning_content_feedback_page = LearningContentFeedbackUK.objects.get(
slug="test-lehrgang-lp-circle-fahrzeug-lc-feedback"
)
self.student = User.objects.get(username="test-student1@example.com")
self.client.force_login(self.student)
def test_creates_response(self):
data = {
"course_negative_feedback": "schlecht",
"course_positive_feedback": "gut",
"feedback_type": "uk",
"goal_attainment": 3,
"preparation_task_clarity": False,
"proficiency": 100,
"satisfaction": 3,
"would_recommend": False,
"instructor_competence": None,
"instructor_respect": None,
"instructor_open_feedback": None,
}
response = self.query(
f"""
mutation {{
send_feedback(
course_session_id: "{COURSE_TEST_ID}"
learning_content_page_id: "{self.learning_content_feedback_page.id}"
learning_content_type: "learnpath.LearningContentFeedbackUK"
data: {{
course_negative_feedback: "{data['course_negative_feedback']}",
course_positive_feedback: "{data['course_positive_feedback']}",
feedback_type: null,
goal_attainment: {data['goal_attainment']},
preparation_task_clarity: {str(data['preparation_task_clarity']).lower()},
proficiency: {data['proficiency']},
satisfaction: {data['satisfaction']},
would_recommend: {str(data['would_recommend']).lower()},
instructor_competence: null,
instructor_respect: null,
instructor_open_feedback: null,
}},
submitted: false
) {{
feedback_response {{
id
data
submitted
__typename
}}
errors {{
field
messages
__typename
}}
__typename
}}
}}
"""
)
content = json.loads(response.content)
self.assertResponseNoErrors(response)
self.assertDictEqual(
content["data"]["send_feedback"]["feedback_response"]["data"], data
)
feedback = FeedbackResponse.objects.first()
self.assertEqual(feedback.data, data)
self.assertEqual(feedback.submitted, False)
self.assertEqual(feedback.feedback_user, self.student)

View File

@ -61,12 +61,13 @@ def get_feedback_for_circle(request, course_session_id, circle_id):
feedback_user__in=feedback_users(course_session_id),
).order_by("created_at")
# I guess this is ok for the üK case
feedback_data = {"amount": len(feedbacks), "questions": {}}
feedback_data = {"amount": len(feedbacks), "questions": {}, "feedbackType": None}
if feedback_data["amount"] == 0:
return Response(status=200, data=feedback_data)
feedback_data["feedbackType"] = feedbacks[0].data.get("feedback_type", None)
for field in FEEDBACK_FIELDS:
feedback_data["questions"][field] = []

View File

@ -0,0 +1,59 @@
import datetime
import requests
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from vbv_lernwelt.core.models import User
from vbv_lernwelt.files.integrations import s3_get_client
from vbv_lernwelt.files.models import UploadFile
class UploadFileIntegrationTest(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.s3_client = s3_get_client()
def setUp(self):
self.user = User.objects.create(username="testuser")
# Creating a dummy file for upload
self.dummy_file = SimpleUploadedFile(
"testfile.txt", b"these are the file contents!"
)
self.upload_file = UploadFile.objects.create(
original_file_name="testfile.txt",
file_name="testfile123.txt",
file_type="text/plain",
uploaded_by=self.user,
file=self.dummy_file,
)
def tearDown(self):
self.upload_file.delete_file()
def test_upload_to_s3(self):
# Verify if file is uploaded to S3
response = self.s3_client.get_object(
Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=str(self.upload_file.file)
)
self.assertEqual(response["Body"].read(), b"these are the file contents!")
def test_url_property(self):
self.upload_file.upload_finished_at = datetime.datetime.now()
self.upload_file.save()
url = self.upload_file.url
response = requests.get(url)
# Assert that the URL is a valid presigned S3 URL and accessible
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b"these are the file contents!")
def test_delete_file_method(self):
file_path = str(self.upload_file.file)
self.upload_file.delete_file()
# Assert that the file is deleted from S3
with self.assertRaises(self.s3_client.exceptions.NoSuchKey):
self.s3_client.get_object(
Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key=file_path
)

View File

@ -0,0 +1,81 @@
import os
import boto3
import requests
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from vbv_lernwelt.core.models import User
from vbv_lernwelt.files.integrations import (
s3_delete_file,
s3_generate_presigned_post,
s3_generate_presigned_url,
s3_get_client,
)
class TestIntegrationsIntegrationTest(TestCase):
@classmethod
def setUpClass(cls):
super().setUpClass()
cls.s3_client = s3_get_client()
def setUp(self):
self.user = User.objects.create(username="testuser")
# Creating a dummy file for upload
self.dummy_file = SimpleUploadedFile(
"testfile.txt", b"these are the file contents!"
)
def test_s3_generate_presigned_post(self):
# Test generating a presigned POST for file upload
presigned_post_data = s3_generate_presigned_post(
file_path=f"{self.user.id}/testfile.txt",
file_type="text/plain",
file_name="testfile.txt",
)
self.assertIn("url", presigned_post_data)
self.assertIn("fields", presigned_post_data)
# Upload file using the presigned URL
files = {"file": self.dummy_file}
response = requests.post(
presigned_post_data["url"], data=presigned_post_data["fields"], files=files
)
self.assertEqual(response.status_code, 204)
def test_s3_generate_presigned_url(self):
# First, manually upload a file to S3 for testing
self.s3_client.upload_fileobj
self.s3_client.upload_fileobj(
self.dummy_file, settings.AWS_STORAGE_BUCKET_NAME, "testfile.txt"
)
# Test generating a presigned URL for the uploaded file
presigned_url = s3_generate_presigned_url(file_path="testfile.txt")
response = requests.get(presigned_url)
self.assertEqual(response.status_code, 200)
self.assertEqual(response.content, b"these are the file contents!")
def test_s3_delete_file(self):
# Upload a file to S3 for testing
self.s3_client.upload_fileobj(
self.dummy_file, settings.AWS_STORAGE_BUCKET_NAME, "testfile.txt"
)
# Test deleting the file
s3_delete_file(file_path="testfile.txt")
# Assert that the file no longer exists
with self.assertRaises(boto3.exceptions.botocore.client.ClientError):
self.s3_client.head_object(
Bucket=settings.AWS_STORAGE_BUCKET_NAME, Key="testfile.txt"
)
@classmethod
def tearDownClass(cls):
# Clean up any remaining files in the S3 bucket
s3_delete_file(file_path="testfile.txt")
super().tearDownClass()

View File

@ -13,7 +13,7 @@ from vbv_lernwelt.course.models import CourseCategory, CoursePage
from vbv_lernwelt.learnpath.tests.learning_path_factories import (
CircleFactory,
LearningContentAssignmentFactory,
LearningContentFeedbackFactory,
LearningContentFeedbackVVFactory,
LearningContentLearningModuleFactory,
LearningContentMediaLibraryFactory,
LearningContentPlaceholderFactory,
@ -201,7 +201,7 @@ def create_circle_basis(lp, title="Basis"):
slug__startswith=f"versicherungsvermittler-in-assignment-reflexion"
),
),
LearningContentFeedbackFactory(
LearningContentFeedbackVVFactory(
parent=circle,
)
@ -278,7 +278,7 @@ def create_circle_gewinnen(lp, title="Gewinnen"):
slug__startswith=f"{course_slug}-assignment-reflexion"
),
),
LearningContentFeedbackFactory(
LearningContentFeedbackVVFactory(
parent=circle,
)
@ -368,7 +368,7 @@ def create_circle_fahrzeug(lp, title="Fahrzeug"):
# slug__startswith=f"{circle.get_course().slug}-assignment-reflexion"
# ),
# ),
LearningContentFeedbackFactory(
LearningContentFeedbackVVFactory(
parent=circle,
)
@ -554,7 +554,7 @@ def create_circle_reisen(lp, title="Reisen"):
slug__startswith=f"{circle.get_course().slug}-assignment-reflexion"
),
),
LearningContentFeedbackFactory(
LearningContentFeedbackVVFactory(
parent=circle,
)
@ -647,7 +647,7 @@ def create_circle_einkommenssicherung(lp, title="Einkommenssicherung"):
slug__startswith=f"{circle.get_course().slug}-assignment-reflexion"
),
),
LearningContentFeedbackFactory(
LearningContentFeedbackVVFactory(
parent=circle,
)
@ -700,7 +700,7 @@ def create_circle_wohneigentum(lp, title="Wohneigentum"):
slug__startswith=f"{circle.get_course().slug}-assignment-reflexion"
),
),
LearningContentFeedbackFactory(
LearningContentFeedbackVVFactory(
parent=circle,
)
@ -782,7 +782,7 @@ def create_circle_pensionierung(lp, title="Pensionierung"):
slug__startswith=f"{circle.get_course().slug}-assignment-reflexion"
),
),
LearningContentFeedbackFactory(
LearningContentFeedbackVVFactory(
parent=circle,
)
@ -839,7 +839,7 @@ def create_circle_erben(lp, title="Erben/Vererben"):
slug__startswith=f"{circle.get_course().slug}-assignment-reflexion"
),
),
LearningContentFeedbackFactory(
LearningContentFeedbackVVFactory(
parent=circle,
)
@ -929,7 +929,7 @@ def create_circle_gesundheit(lp, title="Gesundheit"):
slug__startswith=f"{circle.get_course().slug}-assignment-reflexion"
),
),
LearningContentFeedbackFactory(
LearningContentFeedbackVVFactory(
parent=circle,
)
@ -1352,7 +1352,7 @@ def create_learning_sequence_transfer(parent, title, lc_praxis_title=None):
slug__startswith=f"versicherungsvermittler-in-assignment-reflexion"
),
),
LearningContentFeedbackFactory(
LearningContentFeedbackVVFactory(
parent=parent,
)

View File

@ -10,7 +10,8 @@ from vbv_lernwelt.learnpath.models import (
LearningContentAttendanceCourse,
LearningContentDocumentList,
LearningContentEdoniqTest,
LearningContentFeedback,
LearningContentFeedbackUK,
LearningContentFeedbackVV,
LearningContentKnowledgeAssessment,
LearningContentLearningModule,
LearningContentMediaLibrary,
@ -49,8 +50,10 @@ class LearningContentInterface(CoursePageInterface):
return LearningContentAssignmentObjectType
elif isinstance(instance, LearningContentAttendanceCourse):
return LearningContentAttendanceCourseObjectType
elif isinstance(instance, LearningContentFeedback):
return LearningContentFeedbackObjectType
elif isinstance(instance, LearningContentFeedbackUK):
return LearningContentFeedbackUKObjectType
elif isinstance(instance, LearningContentFeedbackVV):
return LearningContentFeedbackVVObjectType
elif isinstance(instance, LearningContentLearningModule):
return LearningContentLearningModuleObjectType
elif isinstance(instance, LearningContentKnowledgeAssessment):
@ -105,9 +108,19 @@ class LearningContentPlaceholderObjectType(DjangoObjectType):
fields = []
class LearningContentFeedbackObjectType(DjangoObjectType):
class LearningContentFeedbackUKObjectType(DjangoObjectType):
class Meta:
model = LearningContentFeedback
model = LearningContentFeedbackUK
interfaces = (
CoursePageInterface,
LearningContentInterface,
)
fields = []
class LearningContentFeedbackVVObjectType(DjangoObjectType):
class Meta:
model = LearningContentFeedbackVV
interfaces = (
CoursePageInterface,
LearningContentInterface,

View File

@ -0,0 +1,60 @@
# Generated by Django 3.2.20 on 2023-11-29 07:27
import django.db.models.deletion
import wagtail.fields
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("wagtailcore", "0089_log_entry_data_json_null_to_object"),
("learnpath", "0011_learningcontentknowledgeassessment"),
]
operations = [
migrations.RenameModel("LearningContentFeedback", "LearningContentFeedbackUK"),
migrations.CreateModel(
name="LearningContentFeedbackVV",
fields=[
(
"page_ptr",
models.OneToOneField(
auto_created=True,
on_delete=django.db.models.deletion.CASCADE,
parent_link=True,
primary_key=True,
serialize=False,
to="wagtailcore.page",
),
),
("minutes", models.PositiveIntegerField(default=15)),
("description", wagtail.fields.RichTextField(blank=True)),
("content_url", models.TextField(blank=True)),
("has_course_completion_status", models.BooleanField(default=True)),
(
"can_user_self_toggle_course_completion",
models.BooleanField(default=False),
),
],
options={
"abstract": False,
},
bases=("wagtailcore.page",),
),
migrations.AlterField(
model_name="learningcontentassignment",
name="assignment_type",
field=models.CharField(
choices=[
("VOLUNTARY_CASEWORK", "VOLUNTARY_CASEWORK"),
("MANDATORY_CASEWORK", "MANDATORY_CASEWORK"),
("PREP_ASSIGNMENT", "PREP_ASSIGNMENT"),
("REFLECTION", "REFLECTION"),
("CONDITION_ACCEPTANCE", "CONDITION_ACCEPTANCE"),
("EDONIQ_TEST", "EDONIQ_TEST"),
],
default="MANDATORY_CASEWORK",
max_length=50,
),
),
]

View File

@ -72,7 +72,8 @@ class Circle(CourseBasePage):
"learnpath.LearningUnit",
"learnpath.LearningContentAssignment",
"learnpath.LearningContentAttendanceCourse",
"learnpath.LearningContentFeedback",
"learnpath.LearningContentFeedbackUK",
"learnpath.LearningContentFeedbackVV",
"learnpath.LearningContentLearningModule",
"learnpath.LearningContentKnowledgeAssessment",
"learnpath.LearningContentMediaLibrary",
@ -318,7 +319,13 @@ class LearningContentPlaceholder(LearningContent):
can_user_self_toggle_course_completion = models.BooleanField(default=True)
class LearningContentFeedback(LearningContent):
class LearningContentFeedbackUK(LearningContent):
parent_page_types = ["learnpath.Circle"]
subpage_types = []
can_user_self_toggle_course_completion = models.BooleanField(default=False)
class LearningContentFeedbackVV(LearningContent):
parent_page_types = ["learnpath.Circle"]
subpage_types = []
can_user_self_toggle_course_completion = models.BooleanField(default=False)

View File

@ -7,7 +7,8 @@ from vbv_lernwelt.learnpath.models import (
LearningContentAttendanceCourse,
LearningContentDocumentList,
LearningContentEdoniqTest,
LearningContentFeedback,
LearningContentFeedbackUK,
LearningContentFeedbackVV,
LearningContentKnowledgeAssessment,
LearningContentLearningModule,
LearningContentMediaLibrary,
@ -120,14 +121,24 @@ class LearningContentPlaceholderFactory(wagtail_factories.PageFactory):
model = LearningContentPlaceholder
class LearningContentFeedbackFactory(wagtail_factories.PageFactory):
title = "Feedback"
class LearningContentFeedbackVVFactory(wagtail_factories.PageFactory):
title = "FeedbackVV"
minutes = 0
content_url = ""
description = RichText("")
class Meta:
model = LearningContentFeedback
model = LearningContentFeedbackVV
class LearningContentFeedbackUKFactory(wagtail_factories.PageFactory):
title = "FeedbackUK"
minutes = 0
content_url = ""
description = RichText("")
class Meta:
model = LearningContentFeedbackUK
class LearningContentLearningModuleFactory(wagtail_factories.PageFactory):

View File

@ -0,0 +1,54 @@
from django.contrib import admin
from vbv_lernwelt.media_files.models import UserDocument, UserImage
@admin.register(UserDocument)
class UserDocumentAdmin(admin.ModelAdmin):
list_display = (
"title",
"file",
"created_at",
"uploaded_by_user",
"file_size",
"file_hash",
)
search_fields = ("title", "uploaded_by_user__username", "tags__name")
list_filter = ("created_at", "uploaded_by_user")
autocomplete_fields = ["uploaded_by_user"]
date_hierarchy = "created_at"
readonly_fields = (
"file_size",
"file_hash",
"created_at",
"uploaded_by_user",
"file",
)
@admin.register(UserImage)
class UserImageAdmin(admin.ModelAdmin):
list_display = (
"title",
"file",
"created_at",
"uploaded_by_user",
"file_size",
)
search_fields = ("title", "uploaded_by_user__username")
list_filter = ("created_at", "uploaded_by_user")
autocomplete_fields = ["uploaded_by_user"]
date_hierarchy = "created_at"
readonly_fields = (
"file_size",
"file_hash",
"created_at",
"uploaded_by_user",
"file",
"tags",
"title",
"focal_point_x",
"focal_point_y",
"focal_point_width",
"focal_point_height",
)

View File

@ -0,0 +1,6 @@
from django.apps import AppConfig
class MediaLibraryConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "vbv_lernwelt.media_files"

View File

@ -0,0 +1,83 @@
import os
import factory
from django.conf import settings
from wagtail.models import Collection
from vbv_lernwelt.media_files.models import ContentDocument, UserDocument
from vbv_lernwelt.media_files.tests.media_library_factories import (
ContentDocumentFactory,
UserDocumentFactory,
)
def delete_default_documents():
"""deletes all documents"""
if "prod" in settings.APP_ENVIRONMENT:
raise Exception("This command must not be used in production environment")
ContentDocument.objects.all().delete()
UserDocument.objects.all().delete()
def create_default_collections():
root, created = Collection.objects.get_or_create(name="Root", depth=0)
def create_default_content_documents():
"""creates a default document for testing purposes"""
path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "./tests/test_documents/"
)
filename = "Vermittler_Motorfahrzeug_Versicherung_Musterlösung.pdf"
document = ContentDocumentFactory(
title="Musterlösung Fahrzeug",
display_text="Musterlösung Fahrzeug",
description="Musterlösung für den Auftrag Fahrzeug",
link_display_text="Dokument laden",
file=factory.django.FileField(
from_path=os.path.join(path, filename), filename=filename
),
)
document.tags.set(("Fahrzeug", "Musterlösung", "Vermittler"))
document.save()
filename = "TestExcelSheet.xlsx"
document = ContentDocumentFactory(
title="Mustertabelle",
display_text="Mustertabelle",
link_display_text="Dokument laden",
file=factory.django.FileField(
from_path=os.path.join(path, filename), filename=filename
),
)
document.tags.set(("Vermittler"))
document.save()
def create_default_user_documents():
"""creates a default document for testing purposes"""
path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "./tests/test_documents/"
)
filename = "FallanalyseTeststudent.pdf"
document = UserDocumentFactory(
title="Lösung Fallanalyse",
file=factory.django.FileField(
from_path=os.path.join(path, filename), filename=filename
),
)
document.save()
filename = "FallanalyseTeststudent.docx"
document = UserDocumentFactory(
title="Lösung Fallanalyse",
file=factory.django.FileField(
from_path=os.path.join(path, filename), filename=filename
),
)
document.save()

View File

@ -0,0 +1,63 @@
import os
from django.conf import settings
from django.core.files import File
from vbv_lernwelt.media_files.models import ContentImage, UserImage
def delete_default_images():
"""deletes all images"""
if "prod" in settings.APP_ENVIRONMENT:
raise Exception("This command must not be used in production environment")
ContentImage.objects.all().delete()
UserImage.objects.all().delete()
def create_default_images():
create_default_content_images()
create_default_user_images()
def create_default_content_images():
path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "./tests/test_images/"
)
images = [
("bike_accident.jpg", "Bike Accident"),
("car_accident.jpg", "Car Accident"),
]
for filename, title in images:
file_path = os.path.join(path, filename)
with open(file_path, "rb") as f:
image, _ = ContentImage.objects.get_or_create(
title=title,
file=File(f, name=filename),
focal_point_x=600,
focal_point_y=600,
focal_point_width=300,
focal_point_height=300,
)
image.tags.set(("Fahrzeug", "Unfall", "Vermittler"))
image.save()
def create_default_user_images():
path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "./tests/test_images/"
)
filename, title = ("user1_profile.jpg", "User1 Profile")
file_path = os.path.join(path, filename)
with open(file_path, "rb") as f:
image, _ = UserImage.objects.get_or_create(
title=title,
file=File(f, name=filename),
focal_point_x=600,
focal_point_y=600,
focal_point_width=300,
focal_point_height=300,
)
image.save()

View File

@ -0,0 +1,18 @@
import graphene
from graphene_django import DjangoObjectType
from vbv_lernwelt.media_files.models import ContentDocument
class ContentDocumentObjectType(DjangoObjectType):
url = graphene.String(source="url")
class Meta:
model = ContentDocument
fields = (
"id",
"display_text",
"description",
"link_display_text",
"thumbnail",
)

View File

@ -0,0 +1,15 @@
import djclick as click
from vbv_lernwelt.media_files.create_default_documents import (
create_default_collections,
create_default_content_documents,
create_default_user_documents,
)
@click.command()
def command():
print("Creating default documents...")
create_default_collections()
create_default_content_documents()
create_default_user_documents()

View File

@ -0,0 +1,9 @@
import djclick as click
from vbv_lernwelt.media_files.create_default_images import create_default_images
@click.command()
def command():
print("Creating default images...")
create_default_images()

View File

@ -0,0 +1,12 @@
import djclick as click
from vbv_lernwelt.media_files.create_default_documents import delete_default_documents
from vbv_lernwelt.media_files.create_default_images import delete_default_images
@click.command()
def command():
print("Deleting all images...")
delete_default_images()
print("Deleting all documents...")
delete_default_documents()

View File

@ -0,0 +1,436 @@
# Generated by Django 3.2.20 on 2023-12-05 16:11
import django.db.models.deletion
import taggit.managers
import wagtail.images.models
import wagtail.models.collections
import wagtail.search.index
from django.conf import settings
from django.db import migrations, models
import vbv_lernwelt.media_files.storage_backends
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
("taggit", "0005_auto_20220424_2025"),
("wagtailcore", "0089_log_entry_data_json_null_to_object"),
]
operations = [
migrations.CreateModel(
name="ContentImage",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=255, verbose_name="title")),
("width", models.IntegerField(editable=False, verbose_name="width")),
("height", models.IntegerField(editable=False, verbose_name="height")),
(
"created_at",
models.DateTimeField(
auto_now_add=True, db_index=True, verbose_name="created at"
),
),
("focal_point_x", models.PositiveIntegerField(blank=True, null=True)),
("focal_point_y", models.PositiveIntegerField(blank=True, null=True)),
(
"focal_point_width",
models.PositiveIntegerField(blank=True, null=True),
),
(
"focal_point_height",
models.PositiveIntegerField(blank=True, null=True),
),
("file_size", models.PositiveIntegerField(editable=False, null=True)),
(
"file_hash",
models.CharField(
blank=True, db_index=True, editable=False, max_length=40
),
),
(
"file",
wagtail.images.models.WagtailImageField(
height_field="height",
storage=vbv_lernwelt.media_files.storage_backends.ContentImagesStorage,
upload_to=wagtail.images.models.get_upload_to,
verbose_name="file",
width_field="width",
),
),
(
"collection",
models.ForeignKey(
default=wagtail.models.collections.get_root_collection_id,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="wagtailcore.collection",
verbose_name="collection",
),
),
(
"tags",
taggit.managers.TaggableManager(
blank=True,
help_text=None,
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="tags",
),
),
(
"uploaded_by_user",
models.ForeignKey(
blank=True,
editable=False,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
verbose_name="uploaded by user",
),
),
],
options={
"abstract": False,
},
bases=(
wagtail.images.models.ImageFileMixin,
wagtail.search.index.Indexed,
models.Model,
),
),
migrations.CreateModel(
name="UserImage",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=255, verbose_name="title")),
("width", models.IntegerField(editable=False, verbose_name="width")),
("height", models.IntegerField(editable=False, verbose_name="height")),
(
"created_at",
models.DateTimeField(
auto_now_add=True, db_index=True, verbose_name="created at"
),
),
("focal_point_x", models.PositiveIntegerField(blank=True, null=True)),
("focal_point_y", models.PositiveIntegerField(blank=True, null=True)),
(
"focal_point_width",
models.PositiveIntegerField(blank=True, null=True),
),
(
"focal_point_height",
models.PositiveIntegerField(blank=True, null=True),
),
("file_size", models.PositiveIntegerField(editable=False, null=True)),
(
"file_hash",
models.CharField(
blank=True, db_index=True, editable=False, max_length=40
),
),
(
"file",
wagtail.images.models.WagtailImageField(
height_field="height",
storage=vbv_lernwelt.media_files.storage_backends.UserImagesStorage,
upload_to=wagtail.images.models.get_upload_to,
verbose_name="file",
width_field="width",
),
),
(
"collection",
models.ForeignKey(
default=wagtail.models.collections.get_root_collection_id,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="wagtailcore.collection",
verbose_name="collection",
),
),
(
"tags",
taggit.managers.TaggableManager(
blank=True,
help_text=None,
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="tags",
),
),
(
"uploaded_by_user",
models.ForeignKey(
blank=True,
editable=False,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
verbose_name="uploaded by user",
),
),
],
options={
"abstract": False,
},
bases=(
wagtail.images.models.ImageFileMixin,
wagtail.search.index.Indexed,
models.Model,
),
),
migrations.CreateModel(
name="UserDocument",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=255, verbose_name="title")),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
),
("file_size", models.PositiveIntegerField(editable=False, null=True)),
(
"file_hash",
models.CharField(blank=True, editable=False, max_length=40),
),
(
"file",
models.FileField(
storage=vbv_lernwelt.media_files.storage_backends.UserDocumentsStorage,
upload_to="documents",
verbose_name="file",
),
),
(
"collection",
models.ForeignKey(
default=wagtail.models.collections.get_root_collection_id,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="wagtailcore.collection",
verbose_name="collection",
),
),
(
"tags",
taggit.managers.TaggableManager(
blank=True,
help_text=None,
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="tags",
),
),
(
"uploaded_by_user",
models.ForeignKey(
blank=True,
editable=False,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
verbose_name="uploaded by user",
),
),
],
options={
"verbose_name": "document",
"verbose_name_plural": "documents",
"abstract": False,
},
bases=(wagtail.search.index.Indexed, models.Model),
),
migrations.CreateModel(
name="ContentDocument",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("title", models.CharField(max_length=255, verbose_name="title")),
(
"created_at",
models.DateTimeField(auto_now_add=True, verbose_name="created at"),
),
("file_size", models.PositiveIntegerField(editable=False, null=True)),
(
"file_hash",
models.CharField(blank=True, editable=False, max_length=40),
),
(
"file",
models.FileField(
storage=vbv_lernwelt.media_files.storage_backends.ContentDocumentsStorage,
upload_to="documents",
verbose_name="file",
),
),
("display_text", models.CharField(default="", max_length=1024)),
("description", models.TextField(blank=True, default="")),
(
"link_display_text",
models.CharField(blank=True, default="", max_length=1024),
),
(
"thumbnail",
models.CharField(blank=True, default="", max_length=1024),
),
(
"collection",
models.ForeignKey(
default=wagtail.models.collections.get_root_collection_id,
on_delete=django.db.models.deletion.CASCADE,
related_name="+",
to="wagtailcore.collection",
verbose_name="collection",
),
),
(
"tags",
taggit.managers.TaggableManager(
blank=True,
help_text=None,
through="taggit.TaggedItem",
to="taggit.Tag",
verbose_name="tags",
),
),
(
"uploaded_by_user",
models.ForeignKey(
blank=True,
editable=False,
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
verbose_name="uploaded by user",
),
),
],
options={
"verbose_name": "document",
"verbose_name_plural": "documents",
"abstract": False,
},
bases=(wagtail.search.index.Indexed, models.Model),
),
migrations.CreateModel(
name="UserImageRendition",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("filter_spec", models.CharField(db_index=True, max_length=255)),
(
"file",
wagtail.images.models.WagtailImageField(
height_field="height",
upload_to=wagtail.images.models.get_rendition_upload_to,
width_field="width",
),
),
("width", models.IntegerField(editable=False)),
("height", models.IntegerField(editable=False)),
(
"focal_point_key",
models.CharField(
blank=True, default="", editable=False, max_length=16
),
),
(
"image",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="renditions",
to="media_files.userimage",
),
),
],
options={
"unique_together": {("image", "filter_spec", "focal_point_key")},
},
bases=(wagtail.images.models.ImageFileMixin, models.Model),
),
migrations.CreateModel(
name="ContentImageRendition",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("filter_spec", models.CharField(db_index=True, max_length=255)),
(
"file",
wagtail.images.models.WagtailImageField(
height_field="height",
upload_to=wagtail.images.models.get_rendition_upload_to,
width_field="width",
),
),
("width", models.IntegerField(editable=False)),
("height", models.IntegerField(editable=False)),
(
"focal_point_key",
models.CharField(
blank=True, default="", editable=False, max_length=16
),
),
(
"image",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="renditions",
to="media_files.contentimage",
),
),
],
options={
"unique_together": {("image", "filter_spec", "focal_point_key")},
},
bases=(wagtail.images.models.ImageFileMixin, models.Model),
),
]

View File

@ -0,0 +1,106 @@
from django.db import models
from django.utils.translation import gettext_lazy as _
from wagtail.documents.models import AbstractDocument, Document
from wagtail.images.models import (
AbstractImage,
AbstractRendition,
get_upload_to,
Image,
WagtailImageField,
)
from vbv_lernwelt.core.admin import User
from vbv_lernwelt.media_files.storage_backends import (
ContentDocumentsStorage,
ContentImagesStorage,
UserDocumentsStorage,
UserImagesStorage,
)
class ContentDocument(AbstractDocument):
"""
Content documents are documents that are handled by the CMS.
"""
file = models.FileField(
upload_to="documents", verbose_name=_("file"), storage=ContentDocumentsStorage
)
display_text = models.CharField(max_length=1024, default="")
description = models.TextField(default="", blank=True)
link_display_text = models.CharField(max_length=1024, default="", blank=True)
thumbnail = models.CharField(default="", max_length=1024, blank=True)
admin_form_fields = Document.admin_form_fields + (
"display_text",
"description",
"link_display_text",
"thumbnail",
)
def has_permission(self, user: User):
# TODO: 20-11-2023 Renzo: add more advanced permission handling
if user.is_authenticated:
return True
class UserDocument(AbstractDocument):
"""
Documents that are uploaded by the user and not visible in the CMS.
Still they are inherited from the Wagtail Document model.
"""
file = models.FileField(
upload_to="documents", verbose_name=_("file"), storage=UserDocumentsStorage
)
class ContentImage(AbstractImage):
"""
Content images are images that are handled by the CMS.
"""
file = WagtailImageField(
verbose_name=_("file"),
upload_to=get_upload_to,
width_field="width",
height_field="height",
storage=ContentImagesStorage,
)
admin_form_fields = Image.admin_form_fields + (
# Then add the field names here to make them appear in the form:
# 'caption',
)
class UserImage(AbstractImage):
"""
User images are images that are uploaded by the user and not visible in the CMS.
Still they are inherited from the Wagtail Image model.
"""
file = WagtailImageField(
verbose_name=_("file"),
upload_to=get_upload_to,
width_field="width",
height_field="height",
storage=UserImagesStorage,
)
class ContentImageRendition(AbstractRendition):
image = models.ForeignKey(
ContentImage, on_delete=models.CASCADE, related_name="renditions"
)
class Meta:
unique_together = (("image", "filter_spec", "focal_point_key"),)
class UserImageRendition(AbstractRendition):
image = models.ForeignKey(
UserImage, on_delete=models.CASCADE, related_name="renditions"
)
class Meta:
unique_together = (("image", "filter_spec", "focal_point_key"),)

View File

@ -0,0 +1,24 @@
from storages.backends.s3boto3 import S3Boto3Storage
# inspired by https://theyashshahs.medium.com/aws-s3-signed-urls-in-django-d9e66853a42f
class ContentDocumentsStorage(S3Boto3Storage):
location = "media/content_documents"
default_acl = "private"
class ContentImagesStorage(S3Boto3Storage):
location = "media/content_images"
default_acl = "private"
class UserDocumentsStorage(S3Boto3Storage):
location = "media/user_documents"
default_acl = "private"
class UserImagesStorage(S3Boto3Storage):
location = "media/user_images"
default_acl = "private"

View File

@ -0,0 +1,9 @@
# -*- coding: utf-8 -*-
#
# Iterativ GmbH
# http://www.iterativ.ch/
#
# Copyright (c) 2015 Iterativ GmbH. All rights reserved.
#
# Created on 2022-08-16
# @author: lorenz.padberg@iterativ.ch

View File

@ -0,0 +1,18 @@
import wagtail_factories
from vbv_lernwelt.media_files.models import ContentDocument, UserDocument
class ContentDocumentFactory(wagtail_factories.DocumentFactory):
link_display_text = "Dokument herunter laden"
description = ""
class Meta:
model = ContentDocument
django_get_or_create = ("title", "description")
class UserDocumentFactory(wagtail_factories.DocumentFactory):
class Meta:
model = UserDocument
django_get_or_create = ("title",)

View File

@ -0,0 +1,49 @@
import datetime
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from wagtail.models import Collection
from vbv_lernwelt.core.create_default_users import create_default_users
from vbv_lernwelt.core.models import User
from vbv_lernwelt.media_files.models import ContentDocument
TITLE = "Musterlösung Fahrzeug"
class TestContentDocumentServing(TestCase):
def setUp(self):
create_default_users()
now_str = str(datetime.datetime.now().strftime("%d-%m-%Y-%H-%M"))
collection, _ = Collection.objects.get_or_create(name="Root", depth=0)
document = ContentDocument.objects.create(
title=TITLE,
display_text="Musterlösung Fahrzeug",
description="Musterlösung für den Auftrag Fahrzeug",
link_display_text="Dokument laden",
file=SimpleUploadedFile(
f"testdocument_{now_str}.txt", b"these are the file contents!"
),
collection=collection,
)
document.save()
def test_download_document_from_wagtail_logged_in_user_200(self):
self.user = User.objects.get(username="admin")
self.client.login(username="admin", password="test")
document = ContentDocument.objects.get(title=TITLE)
client = self.client
self.assertEqual(
document.url, f"/server/documents/{document.id}/{document.filename}"
)
response = client.get(document.url)
self.assertEqual(response.status_code, 200)
def test_download_document_from_wagtail_anonymous_user_redirect_to_login(self):
document = ContentDocument.objects.get(title=TITLE)
self.client.logout()
response = self.client.get(document.url)
self.assertEqual(response.status_code, 302)
self.assertTrue("login" in response.url)

View File

@ -0,0 +1,88 @@
import datetime
from unittest import skipIf
from django.conf import settings
from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import override_settings, TestCase
from wagtail.models import Collection
from vbv_lernwelt.media_files.models import ContentDocument
TITLE = "Musterlösung Fahrzeug"
class TestContentDocumentStorage(TestCase):
@override_settings(FILE_UPLOAD_STORAGE="s3")
def setUp(self):
now_str = str(datetime.datetime.now().strftime("%d-%m-%Y-%H"))
collection, _ = Collection.objects.get_or_create(name="Root", depth=0)
document = ContentDocument.objects.create(
title=TITLE,
display_text="Musterlösung Fahrzeug",
description="Musterlösung für den Auftrag Fahrzeug",
link_display_text="Dokument laden",
file=SimpleUploadedFile(
f"testdocument_{now_str}.txt", b"these are the file contents!"
),
collection=collection,
)
document.save()
def tearDown(self):
for doc in ContentDocument.objects.all():
doc.file.storage.delete(doc.file.name)
doc.delete()
def test_new_document_is_created(self):
self.assertEqual(ContentDocument.objects.all().count(), 1)
self.assertEqual(ContentDocument.objects.filter(title=TITLE).count(), 1)
def test_document_exists_on_s3(self):
document = ContentDocument.objects.get(title=TITLE)
self.assertTrue(document.file.storage.exists(document.file.name))
def test_download_document_from_s3(self):
document = ContentDocument.objects.get(title=TITLE)
self.assertEqual(document.file.read(), b"these are the file contents!")
def test_delete_document_from_s3(self):
document = ContentDocument.objects.get(title=TITLE)
document.file.storage.delete(document.file.name)
document.delete()
self.assertFalse(document.file.storage.exists(document.file.name))
@skipIf(
settings.AWS_S3_FILE_OVERWRITE,
"This test only works if AWS_S3_FILE_OVERWRITE is False",
)
def test_duplicate_title_and_filename(self):
collection, _ = Collection.objects.get_or_create(name="Root", depth=0)
document = ContentDocument.objects.create(
title=TITLE,
display_text="Musterlösung Fahrzeug",
description="Musterlösung für den Auftrag Fahrzeug",
link_display_text="Dokument laden",
file=SimpleUploadedFile(
"testdocument.txt", b"these are the file contents! For sure!"
),
collection=collection,
)
document2 = ContentDocument.objects.create(
title=TITLE,
display_text="Musterlösung Fahrzeug",
description="Musterlösung für den Auftrag Fahrzeug",
link_display_text="Dokument laden",
file=SimpleUploadedFile(
"testdocument.txt", b"these are the file contents! But different!"
),
collection=collection,
)
self.assertEqual(
document.file.read(), b"these are the file contents! For sure!"
)
self.assertEqual(
document2.file.read(), b"these are the file contents! But different!"
)
self.assertTrue(document.file.name != document2.file.name)

Binary file not shown.

After

Width:  |  Height:  |  Size: 627 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 638 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 105 KiB

View File

@ -1,55 +0,0 @@
import os
import factory
from wagtail.core.models import Collection
from vbv_lernwelt.course.models import Course
from vbv_lernwelt.media_library.models import LibraryDocument
from vbv_lernwelt.media_library.tests.media_library_factories import (
LibraryDocumentFactory,
)
def create_default_collections():
c = Collection.objects.all().delete()
root, created = Collection.objects.get_or_create(name="Root", depth=0)
for course in Course.objects.all():
course_collection = root.add_child(name=course.title)
for cat in course.coursecategory_set.all():
cat_collection = course_collection.add_child(name=cat.title)
def create_default_documents():
LibraryDocument.objects.all().delete()
path = os.path.join(
os.path.dirname(os.path.abspath(__file__)), "../static/media/documents/"
)
collection = Collection.objects.get(name="Fahrzeug")
filename = "SchweizerischesZivilgesetzbuch.pdf"
document = LibraryDocumentFactory(
title="V1 C25 ZGB CH",
display_text="Schweizerisches Zivilgesetzbuch",
description="Ein wundervolles Dokument, Bachblüten für Leseratten und metaphysisches Wohlbefinden für Handyvekäufer.",
link_display_text="Dokument laden",
file=factory.django.FileField(
from_path=os.path.join(path, filename), filename=filename
),
collection=collection,
)
filename = "SmallPDF.pdf"
document = LibraryDocumentFactory(
title="V1 C25 ",
display_text="Pdf showcase ",
description="Ein wundervolles Dokument, Bachblüten für Leseratten und metaphysisches Wohlbefinden für Handyvekäufer.",
link_display_text="Dokument laden",
file=factory.django.FileField(
from_path=os.path.join(path, filename), filename=filename
),
collection=collection,
)

View File

@ -106,19 +106,19 @@ class TestAssignmentCourseRemindersTest(TestCase):
notification = Notification.objects.get(
recipient__username=expected_recipient
)
self.assertEquals(action_object, notification.action_object)
self.assertEquals("ASSIGNMENT_REMINDER", notification.notification_trigger)
self.assertEquals("INFORMATION", notification.notification_category)
self.assertEquals(EXPECTED_MEMBER_VERB, notification.verb)
self.assertEqual(action_object, notification.action_object)
self.assertEqual("ASSIGNMENT_REMINDER", notification.notification_trigger)
self.assertEqual("INFORMATION", notification.notification_category)
self.assertEqual(EXPECTED_MEMBER_VERB, notification.verb)
template_data = notification.data["template_data"]
self.assertEquals(
self.assertEqual(
action_object.learning_content.get_parent_circle().title,
template_data["circle"],
)
self.assertEquals(
self.assertEqual(
action_object.learning_content.get_frontend_url(),
notification.target_url,
)
@ -140,17 +140,17 @@ class TestAssignmentCourseRemindersTest(TestCase):
)
if assignment_type == AssignmentType.CASEWORK:
self.assertEquals(
self.assertEqual(
EmailTemplate.ASSIGNMENT_REMINDER_CASEWORK_MEMBER.name,
email_template,
)
elif assignment_type == AssignmentType.PREP_ASSIGNMENT:
self.assertEquals(
self.assertEqual(
EmailTemplate.ASSIGNMENT_REMINDER_PREP_ASSIGNMENT_MEMBER.name,
email_template,
)
elif type(action_object) == CourseSessionEdoniqTest:
self.assertEquals(
self.assertEqual(
EmailTemplate.ASSIGNMENT_REMINDER_EDONIQ_MEMBER.name,
email_template,
)
@ -176,7 +176,7 @@ class TestAssignmentCourseRemindersTest(TestCase):
send_assignment_reminder_notifications()
# THEN
self.assertEquals(3, len(Notification.objects.all()))
self.assertEqual(3, len(Notification.objects.all()))
self._assert_member_assignment_notifications(
action_object=should_be_sent,
expected_recipients=RECIPIENT_STUDENTS,
@ -214,7 +214,7 @@ class TestAssignmentCourseRemindersTest(TestCase):
send_assignment_reminder_notifications()
# THEN
self.assertEquals(3, len(Notification.objects.all()))
self.assertEqual(3, len(Notification.objects.all()))
self._assert_member_assignment_notifications(
action_object=casework,
expected_recipients=RECIPIENT_STUDENTS,
@ -236,16 +236,16 @@ class TestAssignmentCourseRemindersTest(TestCase):
send_assignment_reminder_notifications()
# THEN
self.assertEquals(1, len(Notification.objects.all()))
self.assertEqual(1, len(Notification.objects.all()))
notification = Notification.objects.get(recipient__username=RECIPIENT_TRAINER)
self.assertEquals(casework, notification.action_object)
self.assertEquals("INFORMATION", notification.notification_category)
self.assertEquals(EXPECTED_EXPERT_VERB, notification.verb)
self.assertEquals(
self.assertEqual(casework, notification.action_object)
self.assertEqual("INFORMATION", notification.notification_category)
self.assertEqual(EXPECTED_EXPERT_VERB, notification.verb)
self.assertEqual(
casework.evaluation_deadline.url_expert, notification.target_url
)
self.assertEquals(
self.assertEqual(
"CASEWORK_EXPERT_EVALUATION_REMINDER", notification.notification_trigger
)
@ -276,7 +276,7 @@ class TestAssignmentCourseRemindersTest(TestCase):
send_assignment_reminder_notifications()
# THEN
self.assertEquals(3, len(Notification.objects.all()))
self.assertEqual(3, len(Notification.objects.all()))
self._assert_member_assignment_notifications(
action_object=prep_assignment,
expected_recipients=RECIPIENT_STUDENTS,

View File

@ -65,52 +65,52 @@ class TestAttendanceCourseReminders(TestCase):
send_attendance_reminder_notifications()
self.assertEquals(4, len(Notification.objects.all()))
self.assertEqual(4, len(Notification.objects.all()))
notification = Notification.objects.get(
recipient__username="test-student1@example.com"
)
self.assertEquals(
self.assertEqual(
"Erinnerung: Bald findet ein Präsenzkurs statt",
notification.verb,
)
self.assertEquals(
self.assertEqual(
"INFORMATION",
notification.notification_category,
)
self.assertEquals(
self.assertEqual(
"ATTENDANCE_COURSE_REMINDER",
notification.notification_trigger,
)
self.assertEquals(
self.assertEqual(
self.csac,
notification.action_object,
)
self.assertEquals(
self.assertEqual(
self.csac.course_session,
notification.course_session,
)
self.assertEquals(
self.assertEqual(
"/course/test-lehrgang/learn/fahrzeug/präsenzkurs-fahrzeug",
notification.target_url,
)
self.assertEquals(
self.assertEqual(
self.csac.learning_content.title,
notification.data["template_data"]["attendance_course"],
)
self.assertEquals(
self.assertEqual(
self.csac.location,
notification.data["template_data"]["location"],
)
self.assertEquals(
self.assertEqual(
self.csac.trainer,
notification.data["template_data"]["trainer"],
)
self.assertEquals(
self.assertEqual(
self.csac.due_date.start.strftime("%d.%m.%Y %H:%M"),
notification.data["template_data"]["start"],
)
self.assertEquals(
self.assertEqual(
self.csac.due_date.end.strftime("%d.%m.%Y %H:%M"),
notification.data["template_data"]["end"],
)

Some files were not shown because too many files have changed in this diff Show More