VBV-213: Filter criteria by selected circle

Show upload button only to circle experts

Add files app and basic frontend test

Add service, refactor form

WIP: Upload file

WIP: Upload file to s3

WIP: Add course models, add view

WIP: Add local upload

WIP: Add basic get

WIP: Validate form

WIP: Add file list, download by name

WIP: Update documents after upload

WIP: Add delete button and API

WIP: Reset upload_finished_at when document is deleted

WIP: Handle upload error

Add s3 document
This commit is contained in:
Daniel Egger 2022-12-21 10:38:27 +01:00 committed by Christian Cueni
parent 6b343805a0
commit 7a3e4324d9
25 changed files with 1174 additions and 64 deletions

View File

@ -0,0 +1,132 @@
<script setup lang="ts">
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import type { DocumentUploadData, DropdownSelectable } from "@/types";
import { onMounted, reactive } from "vue";
import { useI18n } from "vue-i18n";
interface Props {
learningSequences: DropdownSelectable[];
showUploadErrorMessage: boolean;
}
const { t } = useI18n();
const props = withDefaults(defineProps<Props>(), {
learningSequences: [],
showUploadErrorMessage: false,
});
const emit = defineEmits<{
(e: "formSubmit", data: object): void;
}>();
const formData = reactive<DocumentUploadData>({
file: null | File,
name: "",
learningSequence: {
id: -1,
name: t("circlePage.documents.chooseSequence"),
},
});
const formErrors = reactive({
file: false,
name: false,
learningSequence: false,
});
function fileChange(e: Event) {
const keys = Object.keys(e.target.files);
formData.file = keys.length > 0 ? e.target.files[keys[0]] : null;
}
function submitForm() {
if (!validateForm()) {
return;
}
emit("formSubmit", formData);
resetFormErrors();
}
function validateForm() {
formErrors.file = formData.file === 0;
formErrors.learningSequence = formData.learningSequence.id === -1;
formErrors.name = formData.name === "";
for (const [_name, value] of Object.entries(formErrors)) {
if (value) {
return false;
}
}
return true;
}
function resetFormErrors() {
for (const [_name, value] of Object.entries(formErrors)) {
value = false;
}
}
function showFileInformation() {
return formData.file || formErrors.file;
}
</script>
<template>
<form @submit.prevent="submitForm()">
<label class="block text-bold" for="upload">
{{ $t("circlePage.documents.fileLabel") }}
</label>
<div
class="btn-secondary mt-4 text-xl relative cursor-pointer"
:class="{ 'mb-8': !showFileInformation(), 'mb-4': showFileInformation() }"
>
<input @change="fileChange" id="upload" type="file" class="absolute opacity-0" />
{{ $t("circlePage.documents.modalAction") }}
</div>
<div v-if="showFileInformation()" class="mb-8">
<div v-if="formData.file">
<p>{{ formData.file.name }}</p>
</div>
<div v-if="formErrors.file">
<p class="text-red-700">{{ $t("circlePage.documents.selectFile") }}</p>
</div>
</div>
<!--p>{{ $t("circlePage.documentsModalInformation") }}</p-->
<div class="mb-8">
<label class="block text-bold mb-4" for="name">
{{ $t("circlePage.documents.modalFileName") }}
</label>
<input v-model="formData.name" id="name" type="text" class="w-1/2 mb-2" />
<p>{{ $t("circlePage.documents.modalNameInformation") }}</p>
<div v-if="formErrors.name">
<p class="text-red-700">{{ $t("circlePage.documents.chooseName") }}</p>
</div>
</div>
<div class="mb-8">
<label class="block text-bold mb-4" for="learningsequnce">
{{ $t("general.learningSequence") }}
</label>
<ItDropdownSelect
v-model="formData.learningSequence"
class="w-full lg:w-96 mt-4 lg:mt-0"
:items="props.learningSequences"
></ItDropdownSelect>
<div v-if="formErrors.learningSequence">
<p class="text-red-700">
{{ $t("circlePage.documents.chooseLearningSequence") }}
</p>
</div>
</div>
<div v-if="showUploadErrorMessage">
<p class="text-red-700 mb-4">
{{ $t("circlePage.documents.uploadErrorMessage") }}
</p>
</div>
<div class="-mx-8 px-8 pt-4 border-t">
<button class="btn-primary text-xl mb-0">
{{ $t("general.save") }}
</button>
</div>
</form>
</template>

View File

@ -1,13 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import type { DropdownSelectable } from "@/types";
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from "@headlessui/vue"; import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from "@headlessui/vue";
import { computed } from "vue"; import { computed } from "vue";
interface DropdownSelectable {
id: number | string;
name: string;
iconName?: string;
}
// https://stackoverflow.com/questions/64775876/vue-3-pass-reactive-object-to-component-with-two-way-binding // https://stackoverflow.com/questions/64775876/vue-3-pass-reactive-object-to-component-with-two-way-binding
interface Props { interface Props {
modelValue: { modelValue: {

View File

@ -42,7 +42,7 @@ export const itPost = (url: RequestInfo, data: unknown, options: RequestInit = {
// @ts-ignore // @ts-ignore
options.headers["X-CSRFToken"] = getCookieValue("csrftoken"); options.headers["X-CSRFToken"] = getCookieValue("csrftoken");
if (options.method === "GET") { if (["GET", "DELETE"].indexOf(options.method) > -1) {
delete options.body; delete options.body;
} }
@ -57,6 +57,10 @@ export const itGet = (url: RequestInfo) => {
return itPost(url, {}, { method: "GET" }); return itPost(url, {}, { method: "GET" });
}; };
export const itDelete = (url: RequestInfo) => {
return itPost(url, {}, { method: "DELETE" });
};
const itGetPromiseCache = new Map<string, Promise<any>>(); const itGetPromiseCache = new Map<string, Promise<any>>();
export function bustItGetCache(key?: string) { export function bustItGetCache(key?: string) {

View File

@ -44,7 +44,11 @@
"fileLabel": "Datei", "fileLabel": "Datei",
"modalFileName": "Name", "modalFileName": "Name",
"modalNameInformation": "Max. 70 Zeichen", "modalNameInformation": "Max. 70 Zeichen",
"chooseSequence": "Wähle eine Lernsequenz aus" "chooseSequence": "Wähle eine Lernsequenz aus",
"selectFile": "Bitte wähle eine Datei aus",
"chooseName": "Bitte wähle einen Namen",
"chooseLearningSequence": "Bitte wähle eine Lernsequenz aus",
"uploadErrorMessage": "Beim Hochladen ist ein Fehler aufgetreten. Bitte versuche es erneut."
} }
}, },
"learningContent": { "learningContent": {

View File

@ -1,23 +1,22 @@
<script setup lang="ts"> <script setup lang="ts">
import CircleDiagram from "@/components/learningPath/CircleDiagram.vue"; import CircleDiagram from "@/components/learningPath/CircleDiagram.vue";
import CircleOverview from "@/components/learningPath/CircleOverview.vue"; import CircleOverview from "@/components/learningPath/CircleOverview.vue";
import DocumentUploadForm from "@/components/learningPath/DocumentUploadForm.vue";
import LearningSequence from "@/components/learningPath/LearningSequence.vue"; import LearningSequence from "@/components/learningPath/LearningSequence.vue";
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
import ItModal from "@/components/ui/ItModal.vue"; import ItModal from "@/components/ui/ItModal.vue";
import log from "loglevel"; import * as log from "loglevel";
import { computed, onMounted, reactive, ref } from "vue"; import { computed, onMounted, ref, watch } from "vue";
import { uploadCircleDocument } from "@/services/files";
import { useAppStore } from "@/stores/app"; import { useAppStore } from "@/stores/app";
import { useCircleStore } from "@/stores/circle"; import { useCircleStore } from "@/stores/circle";
import { useCourseSessionsStore } from "@/stores/courseSessions"; import { useCourseSessionsStore } from "@/stores/courseSessions";
import type { CourseSessionUser } from "@/types"; import type { CourseSessionUser, DocumentUploadData } from "@/types";
import { humanizeDuration } from "@/utils/humanizeDuration"; import { humanizeDuration } from "@/utils/humanizeDuration";
import _ from "lodash"; import _ from "lodash";
import { useI18n } from "vue-i18n";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
const route = useRoute(); const route = useRoute();
const { t } = useI18n();
const courseSessionsStore = useCourseSessionsStore(); const courseSessionsStore = useCourseSessionsStore();
interface Props { interface Props {
@ -35,14 +34,7 @@ const props = withDefaults(defineProps<Props>(), {
log.debug("CirclePage created", props.readonly, props.profileUser); log.debug("CirclePage created", props.readonly, props.profileUser);
const showUploadModal = ref(false); const showUploadModal = ref(false);
const formData = reactive({ const showUploadErrorMessage = ref(false);
file: "",
name: "",
learningSequence: {
id: -1,
name: t("circlePage.documents.chooseSequence"),
},
});
const appStore = useAppStore(); const appStore = useAppStore();
appStore.showMainNavigationBar = true; appStore.showMainNavigationBar = true;
@ -65,6 +57,8 @@ const dropdownLearningSequences = computed(() =>
})) }))
); );
watch(showUploadModal, (_v) => (showUploadErrorMessage.value = false));
onMounted(async () => { onMounted(async () => {
log.debug( log.debug(
"CirclePage mounted", "CirclePage mounted",
@ -112,6 +106,22 @@ onMounted(async () => {
log.error(error); log.error(error);
} }
}); });
async function uploadDocument(data: DocumentUploadData) {
showUploadErrorMessage.value = false;
try {
const newDocument = await uploadCircleDocument(
data,
courseSessionsStore.courseSessionForRoute?.id
);
const courseSessionStore = useCourseSessionsStore();
courseSessionStore.addDocument(newDocument);
showUploadModal.value = false;
} catch (error) {
log.error(error);
showUploadErrorMessage.value = true;
}
}
</script> </script>
<template> <template>
@ -171,7 +181,6 @@ onMounted(async () => {
<div class="w-full mt-8"> <div class="w-full mt-8">
<CircleDiagram></CircleDiagram> <CircleDiagram></CircleDiagram>
</div> </div>
<div v-if="!props.readonly" class="border-t-2 mt-4 lg:hidden"> <div v-if="!props.readonly" class="border-t-2 mt-4 lg:hidden">
<div <div
class="mt-4 inline-flex items-center" class="mt-4 inline-flex items-center"
@ -202,12 +211,34 @@ onMounted(async () => {
Erfahre mehr dazu Erfahre mehr dazu
</button> </button>
</div> </div>
<div v-if="!props.readonly" class="block border mt-8 p-6"> <div v-if="!props.readonly" class="block border mt-8 p-6">
<h3 class="text-blue-dark"> <h3 class="text-blue-dark">
{{ $t("circlePage.documents.title") }} {{ $t("circlePage.documents.title") }}
</h3> </h3>
<div v-if="courseSessionsStore.hasCockpit"> <ol v-if="courseSessionsStore.circleDocuments?.length > 0">
<li
v-for="learningSequence of courseSessionsStore.circleDocuments"
:key="learningSequence.id"
>
<h4 class="text-bold mt-4">{{ learningSequence.title }}</h4>
<ul>
<li v-for="document of learningSequence.documents">
<a :href="document.url" download>
<span>{{ document.name }}</span>
</a>
<button
v-if="courseSessionsStore.canUploadCircleDocuments"
type="button"
class="w-3 h-3 ml-2 leading-6 inline-block cursor-pointer relative top-[1px]"
@click="courseSessionsStore.removeDocument(document.id)"
>
<it-icon-close class="w-3 h-3"></it-icon-close>
</button>
</li>
</ul>
</li>
</ol>
<div v-if="courseSessionsStore.canUploadCircleDocuments">
<div class="leading-relaxed mt-4"> <div class="leading-relaxed mt-4">
{{ $t("circlePage.documents.description") }} {{ $t("circlePage.documents.description") }}
</div> </div>
@ -250,38 +281,11 @@ onMounted(async () => {
<ItModal v-model="showUploadModal"> <ItModal v-model="showUploadModal">
<template #title>{{ $t("circlePage.documents.action") }}</template> <template #title>{{ $t("circlePage.documents.action") }}</template>
<template #body> <template #body>
<form> <DocumentUploadForm
<label class="block text-bold" for="upload"> @form-submit="uploadDocument"
{{ $t("circlePage.documents.fileLabel") }} :learning-sequences="dropdownLearningSequences"
</label> :show-upload-error-message="showUploadErrorMessage"
<div class="btn-secondary mt-4 mb-8 text-xl relative cursor-pointer"> />
<input id="upload" type="file" class="absolute opacity-0" />
{{ $t("circlePage.documents.modalAction") }}
</div>
<!--p>{{ $t("circlePage.documentsModalInformation") }}</p-->
<div class="mb-8">
<label class="block text-bold mb-4" for="name">
{{ $t("circlePage.documents.modalFileName") }}
</label>
<input id="name" type="text" class="w-1/2 mb-2" />
<p>{{ $t("circlePage.documents.modalNameInformation") }}</p>
</div>
<div class="mb-8">
<label class="block text-bold mb-4" for="learningsequnce">
{{ $t("general.learningSequence") }}
</label>
<ItDropdownSelect
v-model="formData.learningSequence"
class="w-full lg:w-96 mt-4 lg:mt-0"
:items="dropdownLearningSequences"
></ItDropdownSelect>
</div>
<div class="-mx-8 px-8 pt-4 border-t">
<button class="btn-primary text-xl mb-0">
{{ $t("general.save") }}
</button>
</div>
</form>
</template> </template>
</ItModal> </ItModal>
</div> </div>

View File

@ -0,0 +1,91 @@
import { itDelete, itFetch, itPost } from "@/fetchHelpers";
import { getCookieValue } from "@/router/guards";
import type { CircleDocument, DocumentUploadData } from "@/types";
async function startFileUpload(fileData: DocumentUploadData, courseSessionId: number) {
return await itPost(`/api/core/document/start`, {
file_type: fileData.file.type,
file_name: fileData.file.name,
name: fileData.name,
learning_sequence: fileData.learningSequence.id,
course_session: courseSessionId,
});
}
function uploadFile(fileData, file: File) {
if (fileData.fields) {
return s3Upload(fileData, file);
} else {
return djUpload(fileData, file);
}
}
function djUpload(fileData, file: File) {
const formData = new FormData();
formData.append("file", file);
const headers = {
Accept: "application/json",
} as HeadersInit;
let options = {
method: "POST",
headers: headers,
body: formData,
};
// @ts-ignore
options.headers["X-CSRFToken"] = getCookieValue("csrftoken");
return itFetch(fileData.url, options).then((response) => {
return response.json().catch(() => {
return Promise.resolve(null);
});
});
}
function s3Upload(fileData, file: File) {
const formData = new FormData();
for (const [name, value] of Object.entries(fileData.fields)) {
formData.append(name, value);
}
formData.append("file", file);
console.log("fetch", formData);
const options = Object.assign({
method: "POST",
body: formData,
});
return itFetch(fileData.url, options).then((response) => {
return response.json().catch(() => {
return Promise.resolve(null);
});
});
}
export async function uploadCircleDocument(
data: DocumentUploadData,
courseSessionId: number
): Promise<CircleDocument> {
const startData = await startFileUpload(data, courseSessionId);
await uploadFile(startData, data.file);
const response = await itPost(`/api/core/file/finish`, {
file_id: startData.id,
});
const newDocument: CircleDocument = {
id: startData.id,
name: data.name,
file_name: data.file.name,
url: response.url,
course_session: courseSessionId,
learning_sequence: data.learningSequence.id,
};
return Promise.resolve(newDocument);
}
export async function deleteCircleDocument(documentId: number) {
return itDelete(`/api/core/document/${documentId}/`);
}

View File

@ -1,17 +1,34 @@
import { itGetCached } from "@/fetchHelpers"; import { itGetCached, itPost } from "@/fetchHelpers";
import type { CourseSession } from "@/types"; import { deleteCircleDocument } from "@/services/files";
import type { CircleExpert, CourseSession, CircleDocument } from "@/types";
import _ from "lodash"; import _ from "lodash";
import log from "loglevel"; import log from "loglevel";
import { defineStore } from "pinia"; import { defineStore } from "pinia";
import { computed, ref } from "vue"; import { computed, ref } from "vue";
import { useRoute } from "vue-router"; import { useRoute } from "vue-router";
import { useCircleStore } from "./circle";
import { useUserStore } from "./user"; import { useUserStore } from "./user";
function loadCourseSessionsData(reload = false) { function loadCourseSessionsData(reload = false) {
log.debug("loadCourseSessionsData called"); log.debug("loadCourseSessionsData called");
const courseSessions = ref<CourseSession[]>([]); const courseSessions = ref<CourseSession[]>([]);
function userExpertCircles(
userId: number,
courseSessionForRoute: CourseSession
): CircleExpert[] {
if (!courseSessionForRoute) {
return [];
}
return courseSessionForRoute.experts.filter((expert) => expert.user_id === userId);
}
export type CourseSessionsStoreState = {
courseSessions: CourseSession[] | undefined;
};
async function loadAndUpdate() { async function loadAndUpdate() {
courseSessions.value = await itGetCached(`/api/course/sessions/`, { courseSessions.value = await itGetCached(`/api/course/sessions/`, {
reload: reload, reload: reload,
@ -60,10 +77,70 @@ export const useCourseSessionsStore = defineStore("courseSessions", () => {
return false; return false;
}); });
const canUploadCircleDocuments = computed(() => {
const userStore = useUserStore();
const circleStore = useCircleStore();
const expertCircles = userExpertCircles(userStore.id, courseSessionForRoute.value);
return (
expertCircles.filter(
(c) => c.circle_translation_key === circleStore.circle?.translation_key
).length > 0
);
});
const circleDocuments = computed(() => {
const circleStore = useCircleStore();
return circleStore.circle?.learningSequences
.map((ls) => ({ id: ls.id, title: ls.title, documents: [] }))
.map((ls: { id: number; title: string; documents: CircleDocument[] }) => {
if (courseSessionForRoute.value === undefined) {
return ls;
}
for (let document of courseSessionForRoute.value.documents) {
if (document.learning_sequence === ls.id) {
ls.documents.push(document);
}
}
return ls;
})
.filter((ls) => ls.documents.length > 0);
});
function addDocument(document: CircleDocument) {
courseSessionForRoute.value?.documents.push(document);
}
async function startUpload() {
log.debug("loadCourseSessionsData called");
courseSessions.value = await itPost(`/api/core/file/start`, {
file_type: "image/png",
file_name: "test.png",
});
};
async function removeDocument(documentId: number) {
await deleteCircleDocument(documentId);
if (courseSessionForRoute.value === undefined) {
return;
}
courseSessionForRoute.value.documents =
courseSessionForRoute.value?.documents.filter((d) => d.id !== documentId);
}
return { return {
courseSessions, courseSessions,
coursesFromCourseSessions, coursesFromCourseSessions,
courseSessionForRoute, courseSessionForRoute,
hasCockpit, hasCockpit,
canUploadCircleDocuments,
circleDocuments,
addDocument,
startUpload,
removeDocument,
}; };
}); });

View File

@ -303,6 +303,12 @@ export interface DropdownListItem {
data: object; data: object;
} }
export interface DropdownSelectable {
id: number | string;
name: string;
iconName?: string;
}
export interface CircleExpert { export interface CircleExpert {
user_id: number; user_id: number;
user_email: string; user_email: string;
@ -313,6 +319,15 @@ export interface CircleExpert {
circle_translation_key: string; circle_translation_key: string;
} }
export interface CircleDocument {
id: number;
name: string;
file_name: string;
url: string;
course_session: number;
learning_sequence: number;
}
export interface CourseSession { export interface CourseSession {
id: number; id: number;
created_at: string; created_at: string;
@ -327,6 +342,7 @@ export interface CourseSession {
media_library_url: string; media_library_url: string;
additional_json_data: unknown; additional_json_data: unknown;
experts: CircleExpert[]; experts: CircleExpert[];
documents: CircleDocument[];
} }
export type Role = "MEMBER" | "EXPERT" | "TUTOR"; export type Role = "MEMBER" | "EXPERT" | "TUTOR";
@ -350,3 +366,13 @@ export interface ExpertSessionUser extends CourseSessionUser {
translation_key: string; translation_key: string;
}[]; }[];
} }
// document upload
export interface DocumentUploadData {
file: File;
name: string;
learningSequence: {
id: number;
name: string;
};
}

41
docs/file_uploads.md Normal file
View File

@ -0,0 +1,41 @@
# File uploads
## S3 Buckets
Files uploaded by users are stored in [S3 Buckets](https://s3.console.aws.amazon.com/s3/buckets?region=eu-west-2).
These buckets are not publicly accessible.
There are buckets for each environment:
- myvbv-dev.iterativ.ch
- myvbv-stage.iterativ.ch
- myvbv-prod.iterativ.ch
## IAM Users
In order to access the buckets a user is required. These users are created in
the [IAM Console](https://console.aws.amazon.com/iam/home?region=eu-west-2#/users).
The users needs the following permissions:
```
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": "s3:ListAllMyBuckets",
"Resource": [
"arn:aws:s3:::*"
]
},
{
"Effect": "Allow",
"Action": "s3:*",
"Resource": [
"arn:aws:s3:::<bucket-name>",
"arn:aws:s3:::<bucket-name>/*"
]
}
]
}
```

Binary file not shown.

View File

@ -2,6 +2,7 @@
Base settings to build other settings files upon. Base settings to build other settings files upon.
""" """
import logging import logging
import os
from pathlib import Path from pathlib import Path
import structlog import structlog
@ -110,6 +111,7 @@ LOCAL_APPS = [
"vbv_lernwelt.competence", "vbv_lernwelt.competence",
"vbv_lernwelt.media_library", "vbv_lernwelt.media_library",
"vbv_lernwelt.feedback", "vbv_lernwelt.feedback",
"vbv_lernwelt.files",
] ]
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps # https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
@ -430,9 +432,9 @@ else:
structlog.configure( structlog.configure(
processors=shared_processors processors=shared_processors
+ [ + [
structlog.stdlib.ProcessorFormatter.wrap_for_formatter, structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
], ],
context_class=dict, context_class=dict,
logger_factory=structlog.stdlib.LoggerFactory(), logger_factory=structlog.stdlib.LoggerFactory(),
wrapper_class=structlog.stdlib.BoundLogger, wrapper_class=structlog.stdlib.BoundLogger,
@ -565,7 +567,6 @@ OAUTH = {
}, },
} }
GRAPHENE = {"SCHEMA": "grapple.schema.schema", "SCHEMA_OUTPUT": "schema.graphql"} GRAPHENE = {"SCHEMA": "grapple.schema.schema", "SCHEMA_OUTPUT": "schema.graphql"}
GRAPPLE = { GRAPPLE = {
"EXPOSE_GRAPHIQL": DEBUG, "EXPOSE_GRAPHIQL": DEBUG,
@ -606,6 +607,31 @@ if APP_ENVIRONMENT == "development":
# https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration # https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration
INSTALLED_APPS += ["django_extensions", "django_watchfiles"] # noqa F405 INSTALLED_APPS += ["django_extensions", "django_watchfiles"] # noqa F405
# S3 BUCKET CONFIGURATION
FILE_UPLOAD_STORAGE = env("FILE_UPLOAD_STORAGE", default="local") # local | s3
if FILE_UPLOAD_STORAGE == "local":
MEDIA_ROOT_NAME = "media"
MEDIA_ROOT = os.path.join(SERVER_ROOT_DIR, MEDIA_ROOT_NAME)
MEDIA_URL = f"/{MEDIA_ROOT_NAME}/"
FILE_MAX_SIZE = env.int("FILE_MAX_SIZE", default=5242880)
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_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_SIGNATURE_VERSION = env("AWS_S3_SIGNATURE_VERSION", default="s3v4")
FILE_MAX_SIZE = env.int("FILE_MAX_SIZE", default=5242880) # 5MB
# 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=60) # seconds
if APP_ENVIRONMENT in ["production", "caprover"] or APP_ENVIRONMENT.startswith( if APP_ENVIRONMENT in ["production", "caprover"] or APP_ENVIRONMENT.startswith(
"caprover" "caprover"
): ):

View File

@ -22,6 +22,10 @@ from vbv_lernwelt.core.views import (
) )
from vbv_lernwelt.course.views import ( from vbv_lernwelt.course.views import (
course_page_api_view, course_page_api_view,
document_delete,
document_direct_upload,
document_upload_finish,
document_upload_start,
get_course_session_users, get_course_session_users,
get_course_sessions, get_course_sessions,
mark_course_completion_view, mark_course_completion_view,
@ -78,6 +82,16 @@ urlpatterns = [
request_course_completion_for_user, request_course_completion_for_user,
name="request_course_completion_for_user"), name="request_course_completion_for_user"),
# test
path(r'api/core/document/start', document_upload_start,
name='file_upload_start'),
path(r'api/core/document/<str:document_id>/', document_delete,
name='document_delete'),
path(r'api/core/file/finish', document_upload_finish,
name='file_upload_finish'),
path(r"api/core/document/local/<str:file_id>/", document_direct_upload,
name='file_upload_local'),
# testing and debug # testing and debug
path('server/raise_error/', path('server/raise_error/',
user_passes_test(lambda u: u.is_superuser, login_url='/login/')( user_passes_test(lambda u: u.is_superuser, login_url='/login/')(

View File

@ -0,0 +1,52 @@
# Generated by Django 3.2.13 on 2022-12-24 20:34
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("files", "0001_initial"),
("learnpath", "0008_alter_learningcontent_contents"),
("course", "0007_coursesessionuser_role"),
]
operations = [
migrations.CreateModel(
name="CircleDocument",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
("created_at", models.DateTimeField(auto_now_add=True)),
("name", models.CharField(max_length=100)),
(
"course_session",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="course.coursesession",
),
),
(
"file",
models.OneToOneField(
on_delete=django.db.models.deletion.CASCADE, to="files.file"
),
),
(
"learning_sequence",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="learnpath.learningsequence",
),
),
],
),
]

View File

@ -7,6 +7,7 @@ from wagtail.models import Page
from vbv_lernwelt.core.model_utils import find_available_slug from vbv_lernwelt.core.model_utils import find_available_slug
from vbv_lernwelt.core.models import User from vbv_lernwelt.core.models import User
from vbv_lernwelt.course.serializer_helpers import get_course_serializer_class from vbv_lernwelt.course.serializer_helpers import get_course_serializer_class
from vbv_lernwelt.files.models import File
class Course(models.Model): class Course(models.Model):
@ -236,3 +237,27 @@ class CourseSessionUser(models.Model):
"avatar_url": self.user.avatar_url, "avatar_url": self.user.avatar_url,
"role": self.role, "role": self.role,
} }
class CircleDocument(models.Model):
created_at = models.DateTimeField(auto_now_add=True)
file = models.OneToOneField(File, on_delete=models.CASCADE)
name = models.CharField(max_length=100)
course_session = models.ForeignKey("course.CourseSession", on_delete=models.CASCADE)
learning_sequence = models.ForeignKey(
"learnpath.LearningSequence", on_delete=models.CASCADE
)
@property
def url(self) -> str:
return self.file.url
@property
def file_name(self) -> str:
return self.file.original_file_name
def delete(self, *args, **kwargs):
self.file.upload_finished_at = None
self.file.save()
return super().delete(*args, **kwargs)

View File

@ -1,6 +1,7 @@
from rest_framework import serializers from rest_framework import serializers
from vbv_lernwelt.course.models import ( from vbv_lernwelt.course.models import (
CircleDocument,
Course, Course,
CourseCategory, CourseCategory,
CourseCompletion, CourseCompletion,
@ -50,6 +51,7 @@ class CourseSessionSerializer(serializers.ModelSerializer):
competence_url = serializers.SerializerMethodField() competence_url = serializers.SerializerMethodField()
media_library_url = serializers.SerializerMethodField() media_library_url = serializers.SerializerMethodField()
experts = serializers.SerializerMethodField() experts = serializers.SerializerMethodField()
documents = serializers.SerializerMethodField()
def get_course(self, obj): def get_course(self, obj):
return CourseSerializer(obj.course).data return CourseSerializer(obj.course).data
@ -86,6 +88,12 @@ class CourseSessionSerializer(serializers.ModelSerializer):
) )
return expert_result return expert_result
def get_documents(self, obj):
documents = CircleDocument.objects.filter(
course_session=obj, file__upload_finished_at__isnull=False
)
return CircleDocumentSerializer(documents, many=True).data
class Meta: class Meta:
model = CourseSession model = CourseSession
fields = [ fields = [
@ -102,4 +110,30 @@ class CourseSessionSerializer(serializers.ModelSerializer):
"media_library_url", "media_library_url",
"course_url", "course_url",
"experts", "experts",
"documents",
] ]
class CircleDocumentSerializer(serializers.ModelSerializer):
class Meta:
model = CircleDocument
fields = [
"id",
"name",
"file_name",
"url",
"course_session",
"learning_sequence",
]
class DocumentUploadStartInputSerializer(serializers.Serializer):
file_name = serializers.CharField()
file_type = serializers.CharField()
name = serializers.CharField()
course_session = serializers.IntegerField()
learning_sequence = serializers.IntegerField()
class DocumentUploadFinishInputSerializer(serializers.Serializer):
file_id = serializers.IntegerField()

View File

@ -1,11 +1,16 @@
import structlog import structlog
from django.shortcuts import get_object_or_404
from rest_framework.decorators import api_view from rest_framework.decorators import api_view
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
from rest_framework.response import Response from rest_framework.response import Response
from wagtail.models import Page from wagtail.models import Page
from vbv_lernwelt.core.utils import api_page_cache_get_or_set from vbv_lernwelt.core.utils import api_page_cache_get_or_set
from vbv_lernwelt.course.models import CourseCompletion, CourseSessionUser from vbv_lernwelt.course.models import (
CircleDocument,
CourseCompletion,
CourseSessionUser,
)
from vbv_lernwelt.course.permissions import ( from vbv_lernwelt.course.permissions import (
course_sessions_for_user_qs, course_sessions_for_user_qs,
has_course_access_by_page_request, has_course_access_by_page_request,
@ -13,8 +18,12 @@ from vbv_lernwelt.course.permissions import (
from vbv_lernwelt.course.serializers import ( from vbv_lernwelt.course.serializers import (
CourseCompletionSerializer, CourseCompletionSerializer,
CourseSessionSerializer, CourseSessionSerializer,
DocumentUploadFinishInputSerializer,
DocumentUploadStartInputSerializer,
) )
from vbv_lernwelt.course.services import mark_course_completion from vbv_lernwelt.course.services import mark_course_completion
from vbv_lernwelt.files.models import File
from vbv_lernwelt.files.services import FileDirectUploadService
from vbv_lernwelt.learnpath.utils import get_wagtail_type from vbv_lernwelt.learnpath.utils import get_wagtail_type
logger = structlog.get_logger(__name__) logger = structlog.get_logger(__name__)
@ -152,3 +161,65 @@ def get_course_session_users(request, course_slug):
except Exception as e: except Exception as e:
logger.error(e) logger.error(e)
return Response({"error": str(e)}, status=404) return Response({"error": str(e)}, status=404)
@api_view(["POST"])
def document_upload_start(request):
# todo: check permissions
serializer = DocumentUploadStartInputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
service = FileDirectUploadService(request.user)
file, presigned_data = service.start(
serializer.validated_data["file_name"], serializer.validated_data["file_type"]
)
document = CircleDocument(
file=file,
name=serializer.validated_data["name"],
course_session_id=serializer.validated_data["course_session"],
learning_sequence_id=serializer.validated_data["learning_sequence"],
)
document.save()
presigned_data["id"] = document.id
return Response(data=presigned_data)
@api_view(["POST"])
def document_upload_finish(request):
serializer = DocumentUploadFinishInputSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
file_id = serializer.validated_data["file_id"]
file = get_object_or_404(File, id=file_id)
if file.uploaded_by != request.user:
raise PermissionDenied()
service = FileDirectUploadService(request.user)
service.finish(file=file)
return Response({"url": file.url})
@api_view(["POST"])
def document_direct_upload(request, file_id):
file = get_object_or_404(File, id=file_id)
file_obj = request.FILES["file"]
service = FileDirectUploadService(request.user)
file = service.upload_local(file=file, file_obj=file_obj)
return Response({"url": file.url})
@api_view(["DELETE"])
def document_delete(request, document_id):
document = get_object_or_404(CircleDocument, id=document_id)
# todo: check real permissoin
document.delete()
return Response(status=200)

View File

View File

@ -0,0 +1,11 @@
from enum import Enum
class FileUploadStrategy(Enum):
STANDARD = "standard"
DIRECT = "direct"
class FileUploadStorage(Enum):
LOCAL = "local"
S3 = "s3"

View File

@ -0,0 +1,115 @@
from functools import lru_cache
from typing import Any, Dict
import boto3
from attrs import define
from vbv_lernwelt.files.utils import assert_settings
@define
class S3Credentials:
access_key_id: str
secret_access_key: str
region_name: str
bucket_name: str
default_acl: str
presigned_expiry: int
max_size: int
@lru_cache
def s3_get_credentials() -> S3Credentials:
required_config = assert_settings(
[
"AWS_S3_ACCESS_KEY_ID",
"AWS_S3_SECRET_ACCESS_KEY",
"AWS_S3_REGION_NAME",
"AWS_STORAGE_BUCKET_NAME",
"AWS_DEFAULT_ACL",
"AWS_PRESIGNED_EXPIRY",
"FILE_MAX_SIZE",
],
"S3 credentials not found.",
)
return S3Credentials(
access_key_id=required_config["AWS_S3_ACCESS_KEY_ID"],
secret_access_key=required_config["AWS_S3_SECRET_ACCESS_KEY"],
region_name=required_config["AWS_S3_REGION_NAME"],
bucket_name=required_config["AWS_STORAGE_BUCKET_NAME"],
default_acl=required_config["AWS_DEFAULT_ACL"],
presigned_expiry=required_config["AWS_PRESIGNED_EXPIRY"],
max_size=required_config["FILE_MAX_SIZE"],
)
def s3_get_client():
credentials = s3_get_credentials()
return boto3.client(
service_name="s3",
aws_access_key_id=credentials.access_key_id,
aws_secret_access_key=credentials.secret_access_key,
region_name=credentials.region_name,
)
def s3_generate_presigned_post(
*, file_path: str, file_type: str, file_name: str
) -> Dict[str, Any]:
credentials = s3_get_credentials()
s3_client = s3_get_client()
acl = credentials.default_acl
expires_in = credentials.presigned_expiry
"""
TODO: Create a type for the presigned_data
It looks like this:
{
'fields': {
'Content-Type': 'image/png',
'acl': 'private',
'key': 'files/<hash>.png',
'policy': 'some-long-base64-string',
'x-amz-algorithm': 'AWS4-HMAC-SHA256',
'x-amz-credential': 'AKIASOZLZI5FJDJ6XTSZ/20220405/eu-central-1/s3/aws4_request',
'x-amz-date': '20220405T114912Z',
'x-amz-signature': '<hash>',
},
'url': 'https://django-styleguide-example.s3.amazonaws.com/'
}
"""
presigned_data = s3_client.generate_presigned_post(
credentials.bucket_name,
file_path,
Fields={
"acl": acl,
"Content-Type": file_type,
f"Content-Disposition": f"attachment; filename={file_name}",
},
Conditions=[
{"acl": acl},
{"Content-Type": file_type},
# As an example, allow file size up to 10 MiB
# More on conditions, here:
# https://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-HTTPPOSTConstructPolicy.html
["content-length-range", 1, credentials.max_size],
["starts-with", "$Content-Disposition", ""],
],
ExpiresIn=expires_in,
)
return presigned_data
def s3_generate_presigned_url(*, file_path: str) -> str:
credentials = s3_get_credentials()
s3_client = s3_get_client()
return s3_client.generate_presigned_url(
"get_object",
Params={"Bucket": credentials.bucket_name, "Key": file_path},
ExpiresIn=credentials.presigned_expiry,
)

View File

@ -0,0 +1,53 @@
# Generated by Django 3.2.13 on 2022-12-22 14:14
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import vbv_lernwelt.files.utils
class Migration(migrations.Migration):
initial = True
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
]
operations = [
migrations.CreateModel(
name="File",
fields=[
(
"id",
models.BigAutoField(
auto_created=True,
primary_key=True,
serialize=False,
verbose_name="ID",
),
),
(
"file",
models.FileField(
blank=True,
null=True,
upload_to=vbv_lernwelt.files.utils.file_generate_upload_path,
),
),
("original_file_name", models.TextField()),
("file_name", models.CharField(max_length=255, unique=True)),
("file_type", models.CharField(max_length=255)),
("upload_finished_at", models.DateTimeField(blank=True, null=True)),
(
"uploaded_by",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.SET_NULL,
to=settings.AUTH_USER_MODEL,
),
),
],
),
]

View File

@ -0,0 +1,75 @@
from importlib import import_module
from typing import Sequence, Type, TYPE_CHECKING
from django.conf import settings
from django.contrib import auth
from rest_framework.authentication import BaseAuthentication, SessionAuthentication
from rest_framework.permissions import BasePermission, IsAuthenticated
def get_auth_header(headers):
value = headers.get("Authorization")
if not value:
return None
auth_type, auth_value = value.split()[:2]
return auth_type, auth_value
class SessionAsHeaderAuthentication(BaseAuthentication):
"""
In case we are dealing with issues like Safari not supporting SameSite=None,
And the client passes the session as Authorization header:
Authorization: Session <session_key>
Run the standard Django auth & try obtaining user.
"""
def authenticate(self, request):
auth_header = get_auth_header(request.headers)
if auth_header is None:
return None
auth_type, auth_value = auth_header
if auth_type != "Session":
return None
engine = import_module(settings.SESSION_ENGINE)
SessionStore = engine.SessionStore
session_key = auth_value
request.session = SessionStore(session_key)
user = auth.get_user(request)
return user, None
class CsrfExemptedSessionAuthentication(SessionAuthentication):
"""
DRF SessionAuthentication is enforcing CSRF, which may be problematic.
That's why we want to make sure we are exempting any kind of CSRF checks for APIs.
"""
def enforce_csrf(self, request):
return
if TYPE_CHECKING:
# This is going to be resolved in the stub library
# https://github.com/typeddjango/djangorestframework-stubs/
from rest_framework.permissions import _PermissionClass
PermissionClassesType = Sequence[_PermissionClass]
else:
PermissionClassesType = Sequence[Type[BasePermission]]
class ApiAuthMixin:
authentication_classes: Sequence[Type[BaseAuthentication]] = [
CsrfExemptedSessionAuthentication,
SessionAsHeaderAuthentication,
]
permission_classes: PermissionClassesType = (IsAuthenticated,)

View File

@ -0,0 +1,41 @@
from django.conf import settings
from django.db import models
from vbv_lernwelt.core.models import User
from vbv_lernwelt.files.enums import FileUploadStorage
from vbv_lernwelt.files.integrations import s3_generate_presigned_url
from vbv_lernwelt.files.utils import file_generate_upload_path
# Inspired by https://www.hacksoft.io/blog/direct-to-s3-file-upload-with-django
# Code https://github.com/HackSoftware/Django-Styleguide-Example/tree/bdadf52b849bb5fa47854a3094f4da6fe9d54d02/styleguide_example/files
class File(models.Model):
file = models.FileField(upload_to=file_generate_upload_path, blank=True, null=True)
original_file_name = models.TextField()
file_name = models.CharField(max_length=255, unique=True)
file_type = models.CharField(max_length=255)
# As a specific behavior,
# We might want to preserve files after the uploader has been deleted.
# In case you want to delete the files too, use models.CASCADE & drop the null=True
uploaded_by = models.ForeignKey(User, null=True, on_delete=models.SET_NULL)
upload_finished_at = models.DateTimeField(blank=True, null=True)
@property
def is_valid(self):
"""
We consider a file "valid" if the datetime flag has value.
"""
return bool(self.upload_finished_at)
@property
def url(self):
if settings.FILE_UPLOAD_STORAGE == FileUploadStorage.S3.value:
return s3_generate_presigned_url(file_path=str(self.file))
return f"{self.file.url}"

View File

@ -0,0 +1,165 @@
import mimetypes
from typing import Any, Dict, Tuple
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils import timezone
from vbv_lernwelt.core.models import User
from vbv_lernwelt.files.enums import FileUploadStorage
from vbv_lernwelt.files.integrations import s3_generate_presigned_post
from vbv_lernwelt.files.models import File
from vbv_lernwelt.files.utils import (
bytes_to_mib,
file_generate_local_upload_url,
file_generate_name,
file_generate_upload_path,
)
def _validate_file_size(file_obj):
max_size = settings.FILE_MAX_SIZE
if file_obj.size > max_size:
raise ValidationError(
f"File is too large. It should not exceed {bytes_to_mib(max_size)} MiB"
)
class FileStandardUploadService:
"""
This also serves as an example of a service class,
which encapsulates 2 different behaviors (create & update) under a namespace.
Meaning, we use the class here for:
1. The namespace
2. The ability to reuse `_infer_file_name_and_type` (which can also be an util)
"""
def __init__(self, user: User, file_obj):
self.user = user
self.file_obj = file_obj
def _infer_file_name_and_type(
self, file_name: str = "", file_type: str = ""
) -> Tuple[str, str]:
if not file_name:
file_name = self.file_obj.name
if not file_type:
guessed_file_type, encoding = mimetypes.guess_type(file_name)
if guessed_file_type is None:
file_type = ""
else:
file_type = guessed_file_type
return file_name, file_type
@transaction.atomic
def create(self, file_name: str = "", file_type: str = "") -> File:
_validate_file_size(self.file_obj)
file_name, file_type = self._infer_file_name_and_type(file_name, file_type)
obj = File(
file=self.file_obj,
original_file_name=file_name,
file_name=file_generate_name(file_name),
file_type=file_type,
uploaded_by=self.user,
upload_finished_at=timezone.now(),
)
obj.full_clean()
obj.save()
return obj
@transaction.atomic
def update(self, file: File, file_name: str = "", file_type: str = "") -> File:
_validate_file_size(self.file_obj)
file_name, file_type = self._infer_file_name_and_type(file_name, file_type)
file.file = self.file_obj
file.original_file_name = file_name
file.file_name = file_generate_name(file_name)
file.file_type = file_type
file.uploaded_by = self.user
file.upload_finished_at = timezone.now()
file.full_clean()
file.save()
return file
class FileDirectUploadService:
"""
This also serves as an example of a service class,
which encapsulates a flow (start & finish) + one-off action (upload_local) into a namespace.
Meaning, we use the class here for:
1. The namespace
"""
def __init__(self, user: User):
self.user = user
@transaction.atomic
def start(self, file_name: str, file_type: str) -> Tuple[File, Dict[str, Any]]:
file = File(
original_file_name=file_name,
file_name=file_generate_name(file_name),
file_type=file_type,
uploaded_by=self.user,
file=None,
)
file.full_clean()
file.save()
upload_path = file_generate_upload_path(file, file.file_name)
"""
We are doing this in order to have an associated file for the field.
"""
file.file = file.file.field.attr_class(file, file.file.field, upload_path)
file.save()
presigned_data: Dict[str, Any] = {}
if settings.FILE_UPLOAD_STORAGE == FileUploadStorage.S3.value:
presigned_data = s3_generate_presigned_post(
file_path=upload_path, file_type=file.file_type, file_name=file_name
)
else:
presigned_data = {
"url": file_generate_local_upload_url(file_id=str(file.id)),
}
return file, presigned_data
@transaction.atomic
def finish(self, *, file: File) -> File:
# Potentially, check against user
file.upload_finished_at = timezone.now()
file.full_clean()
file.save()
return file
@transaction.atomic
def upload_local(self, *, file: File, file_obj) -> File:
_validate_file_size(file_obj)
# Potentially, check against user
file.file = file_obj
file.full_clean()
file.save()
return file

View File

@ -0,0 +1,54 @@
import pathlib
from uuid import uuid4
from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
from django.urls import reverse
def file_generate_name(original_file_name):
extension = pathlib.Path(original_file_name).suffix
return f"{uuid4().hex}{extension}"
def file_generate_upload_path(instance, _filename):
return f"circledocuments/{instance.file_name}"
def file_generate_local_upload_url(*, file_id: str):
url = reverse("file_upload_local", kwargs={"file_id": file_id})
return url
def bytes_to_mib(value: int) -> float:
# 1 bytes = 9.5367431640625E-7 mebibytes
return value * 9.5367431640625e-7
def assert_settings(required_settings, error_message_prefix=""):
"""
Checks if each item from `required_settings` is present in Django settings
"""
not_present = []
values = {}
for required_setting in required_settings:
if not hasattr(settings, required_setting):
not_present.append(required_setting)
continue
values[required_setting] = getattr(settings, required_setting)
if not_present:
if not error_message_prefix:
error_message_prefix = "Required settings not found."
stringified_not_present = ", ".join(not_present)
raise ImproperlyConfigured(
f"{error_message_prefix} Could not find: {stringified_not_present}"
)
return values