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:
parent
6b343805a0
commit
7a3e4324d9
|
|
@ -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>
|
||||
|
|
@ -1,13 +1,8 @@
|
|||
<script setup lang="ts">
|
||||
import type { DropdownSelectable } from "@/types";
|
||||
import { Listbox, ListboxButton, ListboxOption, ListboxOptions } from "@headlessui/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
|
||||
interface Props {
|
||||
modelValue: {
|
||||
|
|
|
|||
|
|
@ -42,7 +42,7 @@ export const itPost = (url: RequestInfo, data: unknown, options: RequestInit = {
|
|||
// @ts-ignore
|
||||
options.headers["X-CSRFToken"] = getCookieValue("csrftoken");
|
||||
|
||||
if (options.method === "GET") {
|
||||
if (["GET", "DELETE"].indexOf(options.method) > -1) {
|
||||
delete options.body;
|
||||
}
|
||||
|
||||
|
|
@ -57,6 +57,10 @@ export const itGet = (url: RequestInfo) => {
|
|||
return itPost(url, {}, { method: "GET" });
|
||||
};
|
||||
|
||||
export const itDelete = (url: RequestInfo) => {
|
||||
return itPost(url, {}, { method: "DELETE" });
|
||||
};
|
||||
|
||||
const itGetPromiseCache = new Map<string, Promise<any>>();
|
||||
|
||||
export function bustItGetCache(key?: string) {
|
||||
|
|
|
|||
|
|
@ -44,7 +44,11 @@
|
|||
"fileLabel": "Datei",
|
||||
"modalFileName": "Name",
|
||||
"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": {
|
||||
|
|
|
|||
|
|
@ -1,23 +1,22 @@
|
|||
<script setup lang="ts">
|
||||
import CircleDiagram from "@/components/learningPath/CircleDiagram.vue";
|
||||
import CircleOverview from "@/components/learningPath/CircleOverview.vue";
|
||||
import DocumentUploadForm from "@/components/learningPath/DocumentUploadForm.vue";
|
||||
import LearningSequence from "@/components/learningPath/LearningSequence.vue";
|
||||
import ItDropdownSelect from "@/components/ui/ItDropdownSelect.vue";
|
||||
import ItModal from "@/components/ui/ItModal.vue";
|
||||
import log from "loglevel";
|
||||
import { computed, onMounted, reactive, ref } from "vue";
|
||||
import * as log from "loglevel";
|
||||
import { computed, onMounted, ref, watch } from "vue";
|
||||
|
||||
import { uploadCircleDocument } from "@/services/files";
|
||||
import { useAppStore } from "@/stores/app";
|
||||
import { useCircleStore } from "@/stores/circle";
|
||||
import { useCourseSessionsStore } from "@/stores/courseSessions";
|
||||
import type { CourseSessionUser } from "@/types";
|
||||
import type { CourseSessionUser, DocumentUploadData } from "@/types";
|
||||
import { humanizeDuration } from "@/utils/humanizeDuration";
|
||||
import _ from "lodash";
|
||||
import { useI18n } from "vue-i18n";
|
||||
import { useRoute } from "vue-router";
|
||||
|
||||
const route = useRoute();
|
||||
const { t } = useI18n();
|
||||
const courseSessionsStore = useCourseSessionsStore();
|
||||
|
||||
interface Props {
|
||||
|
|
@ -35,14 +34,7 @@ const props = withDefaults(defineProps<Props>(), {
|
|||
log.debug("CirclePage created", props.readonly, props.profileUser);
|
||||
|
||||
const showUploadModal = ref(false);
|
||||
const formData = reactive({
|
||||
file: "",
|
||||
name: "",
|
||||
learningSequence: {
|
||||
id: -1,
|
||||
name: t("circlePage.documents.chooseSequence"),
|
||||
},
|
||||
});
|
||||
const showUploadErrorMessage = ref(false);
|
||||
|
||||
const appStore = useAppStore();
|
||||
appStore.showMainNavigationBar = true;
|
||||
|
|
@ -65,6 +57,8 @@ const dropdownLearningSequences = computed(() =>
|
|||
}))
|
||||
);
|
||||
|
||||
watch(showUploadModal, (_v) => (showUploadErrorMessage.value = false));
|
||||
|
||||
onMounted(async () => {
|
||||
log.debug(
|
||||
"CirclePage mounted",
|
||||
|
|
@ -112,6 +106,22 @@ onMounted(async () => {
|
|||
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>
|
||||
|
||||
<template>
|
||||
|
|
@ -171,7 +181,6 @@ onMounted(async () => {
|
|||
<div class="w-full mt-8">
|
||||
<CircleDiagram></CircleDiagram>
|
||||
</div>
|
||||
|
||||
<div v-if="!props.readonly" class="border-t-2 mt-4 lg:hidden">
|
||||
<div
|
||||
class="mt-4 inline-flex items-center"
|
||||
|
|
@ -202,12 +211,34 @@ onMounted(async () => {
|
|||
Erfahre mehr dazu
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="!props.readonly" class="block border mt-8 p-6">
|
||||
<h3 class="text-blue-dark">
|
||||
{{ $t("circlePage.documents.title") }}
|
||||
</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">
|
||||
{{ $t("circlePage.documents.description") }}
|
||||
</div>
|
||||
|
|
@ -250,38 +281,11 @@ onMounted(async () => {
|
|||
<ItModal v-model="showUploadModal">
|
||||
<template #title>{{ $t("circlePage.documents.action") }}</template>
|
||||
<template #body>
|
||||
<form>
|
||||
<label class="block text-bold" for="upload">
|
||||
{{ $t("circlePage.documents.fileLabel") }}
|
||||
</label>
|
||||
<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>
|
||||
<DocumentUploadForm
|
||||
@form-submit="uploadDocument"
|
||||
:learning-sequences="dropdownLearningSequences"
|
||||
:show-upload-error-message="showUploadErrorMessage"
|
||||
/>
|
||||
</template>
|
||||
</ItModal>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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}/`);
|
||||
}
|
||||
|
|
@ -1,17 +1,34 @@
|
|||
import { itGetCached } from "@/fetchHelpers";
|
||||
import type { CourseSession } from "@/types";
|
||||
import { itGetCached, itPost } from "@/fetchHelpers";
|
||||
import { deleteCircleDocument } from "@/services/files";
|
||||
import type { CircleExpert, CourseSession, CircleDocument } from "@/types";
|
||||
import _ from "lodash";
|
||||
import log from "loglevel";
|
||||
|
||||
import { defineStore } from "pinia";
|
||||
import { computed, ref } from "vue";
|
||||
import { useRoute } from "vue-router";
|
||||
import { useCircleStore } from "./circle";
|
||||
import { useUserStore } from "./user";
|
||||
|
||||
function loadCourseSessionsData(reload = false) {
|
||||
log.debug("loadCourseSessionsData called");
|
||||
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() {
|
||||
courseSessions.value = await itGetCached(`/api/course/sessions/`, {
|
||||
reload: reload,
|
||||
|
|
@ -60,10 +77,70 @@ export const useCourseSessionsStore = defineStore("courseSessions", () => {
|
|||
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 {
|
||||
courseSessions,
|
||||
coursesFromCourseSessions,
|
||||
courseSessionForRoute,
|
||||
hasCockpit,
|
||||
canUploadCircleDocuments,
|
||||
circleDocuments,
|
||||
addDocument,
|
||||
startUpload,
|
||||
removeDocument,
|
||||
};
|
||||
|
||||
});
|
||||
|
|
|
|||
|
|
@ -303,6 +303,12 @@ export interface DropdownListItem {
|
|||
data: object;
|
||||
}
|
||||
|
||||
export interface DropdownSelectable {
|
||||
id: number | string;
|
||||
name: string;
|
||||
iconName?: string;
|
||||
}
|
||||
|
||||
export interface CircleExpert {
|
||||
user_id: number;
|
||||
user_email: string;
|
||||
|
|
@ -313,6 +319,15 @@ export interface CircleExpert {
|
|||
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 {
|
||||
id: number;
|
||||
created_at: string;
|
||||
|
|
@ -327,6 +342,7 @@ export interface CourseSession {
|
|||
media_library_url: string;
|
||||
additional_json_data: unknown;
|
||||
experts: CircleExpert[];
|
||||
documents: CircleDocument[];
|
||||
}
|
||||
|
||||
export type Role = "MEMBER" | "EXPERT" | "TUTOR";
|
||||
|
|
@ -350,3 +366,13 @@ export interface ExpertSessionUser extends CourseSessionUser {
|
|||
translation_key: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
// document upload
|
||||
export interface DocumentUploadData {
|
||||
file: File;
|
||||
name: string;
|
||||
learningSequence: {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -2,6 +2,7 @@
|
|||
Base settings to build other settings files upon.
|
||||
"""
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
|
||||
import structlog
|
||||
|
|
@ -110,6 +111,7 @@ LOCAL_APPS = [
|
|||
"vbv_lernwelt.competence",
|
||||
"vbv_lernwelt.media_library",
|
||||
"vbv_lernwelt.feedback",
|
||||
"vbv_lernwelt.files",
|
||||
]
|
||||
# https://docs.djangoproject.com/en/dev/ref/settings/#installed-apps
|
||||
INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS
|
||||
|
|
@ -430,9 +432,9 @@ else:
|
|||
|
||||
structlog.configure(
|
||||
processors=shared_processors
|
||||
+ [
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
],
|
||||
+ [
|
||||
structlog.stdlib.ProcessorFormatter.wrap_for_formatter,
|
||||
],
|
||||
context_class=dict,
|
||||
logger_factory=structlog.stdlib.LoggerFactory(),
|
||||
wrapper_class=structlog.stdlib.BoundLogger,
|
||||
|
|
@ -565,7 +567,6 @@ OAUTH = {
|
|||
},
|
||||
}
|
||||
|
||||
|
||||
GRAPHENE = {"SCHEMA": "grapple.schema.schema", "SCHEMA_OUTPUT": "schema.graphql"}
|
||||
GRAPPLE = {
|
||||
"EXPOSE_GRAPHIQL": DEBUG,
|
||||
|
|
@ -606,6 +607,31 @@ if APP_ENVIRONMENT == "development":
|
|||
# https://django-extensions.readthedocs.io/en/latest/installation_instructions.html#configuration
|
||||
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(
|
||||
"caprover"
|
||||
):
|
||||
|
|
|
|||
|
|
@ -22,6 +22,10 @@ from vbv_lernwelt.core.views import (
|
|||
)
|
||||
from vbv_lernwelt.course.views import (
|
||||
course_page_api_view,
|
||||
document_delete,
|
||||
document_direct_upload,
|
||||
document_upload_finish,
|
||||
document_upload_start,
|
||||
get_course_session_users,
|
||||
get_course_sessions,
|
||||
mark_course_completion_view,
|
||||
|
|
@ -78,6 +82,16 @@ urlpatterns = [
|
|||
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
|
||||
path('server/raise_error/',
|
||||
user_passes_test(lambda u: u.is_superuser, login_url='/login/')(
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -7,6 +7,7 @@ from wagtail.models import Page
|
|||
from vbv_lernwelt.core.model_utils import find_available_slug
|
||||
from vbv_lernwelt.core.models import User
|
||||
from vbv_lernwelt.course.serializer_helpers import get_course_serializer_class
|
||||
from vbv_lernwelt.files.models import File
|
||||
|
||||
|
||||
class Course(models.Model):
|
||||
|
|
@ -236,3 +237,27 @@ class CourseSessionUser(models.Model):
|
|||
"avatar_url": self.user.avatar_url,
|
||||
"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)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
from rest_framework import serializers
|
||||
|
||||
from vbv_lernwelt.course.models import (
|
||||
CircleDocument,
|
||||
Course,
|
||||
CourseCategory,
|
||||
CourseCompletion,
|
||||
|
|
@ -50,6 +51,7 @@ class CourseSessionSerializer(serializers.ModelSerializer):
|
|||
competence_url = serializers.SerializerMethodField()
|
||||
media_library_url = serializers.SerializerMethodField()
|
||||
experts = serializers.SerializerMethodField()
|
||||
documents = serializers.SerializerMethodField()
|
||||
|
||||
def get_course(self, obj):
|
||||
return CourseSerializer(obj.course).data
|
||||
|
|
@ -86,6 +88,12 @@ class CourseSessionSerializer(serializers.ModelSerializer):
|
|||
)
|
||||
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:
|
||||
model = CourseSession
|
||||
fields = [
|
||||
|
|
@ -102,4 +110,30 @@ class CourseSessionSerializer(serializers.ModelSerializer):
|
|||
"media_library_url",
|
||||
"course_url",
|
||||
"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()
|
||||
|
|
|
|||
|
|
@ -1,11 +1,16 @@
|
|||
import structlog
|
||||
from django.shortcuts import get_object_or_404
|
||||
from rest_framework.decorators import api_view
|
||||
from rest_framework.exceptions import PermissionDenied
|
||||
from rest_framework.response import Response
|
||||
from wagtail.models import Page
|
||||
|
||||
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 (
|
||||
course_sessions_for_user_qs,
|
||||
has_course_access_by_page_request,
|
||||
|
|
@ -13,8 +18,12 @@ from vbv_lernwelt.course.permissions import (
|
|||
from vbv_lernwelt.course.serializers import (
|
||||
CourseCompletionSerializer,
|
||||
CourseSessionSerializer,
|
||||
DocumentUploadFinishInputSerializer,
|
||||
DocumentUploadStartInputSerializer,
|
||||
)
|
||||
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
|
||||
|
||||
logger = structlog.get_logger(__name__)
|
||||
|
|
@ -152,3 +161,65 @@ def get_course_session_users(request, course_slug):
|
|||
except Exception as e:
|
||||
logger.error(e)
|
||||
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)
|
||||
|
|
|
|||
|
|
@ -0,0 +1,11 @@
|
|||
from enum import Enum
|
||||
|
||||
|
||||
class FileUploadStrategy(Enum):
|
||||
STANDARD = "standard"
|
||||
DIRECT = "direct"
|
||||
|
||||
|
||||
class FileUploadStorage(Enum):
|
||||
LOCAL = "local"
|
||||
S3 = "s3"
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
|
|
@ -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,)
|
||||
|
|
@ -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}"
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
Loading…
Reference in New Issue