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">
|
<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: {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
||||||
|
|
|
||||||
|
|
@ -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": {
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
|
|
@ -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 { 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -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.
|
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"
|
||||||
):
|
):
|
||||||
|
|
|
||||||
|
|
@ -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/')(
|
||||||
|
|
|
||||||
|
|
@ -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.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)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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