VBV-213: Filter criteria by selected circle

Show upload button only to circle experts

Add files app and basic frontend test

Add service, refactor form

WIP: Upload file

WIP: Upload file to s3

WIP: Add course models, add view

WIP: Add local upload

WIP: Add basic get

WIP: Validate form

WIP: Add file list, download by name

WIP: Update documents after upload

WIP: Add delete button and API

WIP: Reset upload_finished_at when document is deleted

WIP: Handle upload error

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

View File

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

View File

@ -1,13 +1,8 @@
<script setup lang="ts">
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: {

View File

@ -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) {

View File

@ -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": {

View File

@ -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>

View File

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

View File

@ -1,17 +1,34 @@
import { itGetCached } from "@/fetchHelpers";
import 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,
};
});

View File

@ -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;
};
}

41
docs/file_uploads.md Normal file
View File

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

Binary file not shown.

View File

@ -2,6 +2,7 @@
Base settings to build other settings files upon.
"""
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"
):

View File

@ -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/')(

View File

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

View File

@ -7,6 +7,7 @@ from wagtail.models import Page
from vbv_lernwelt.core.model_utils import find_available_slug
from vbv_lernwelt.core.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)

View File

@ -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()

View File

@ -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)

View File

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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