+
+ -
+
{{ learningSequence.title }}
+
+
+
+
{{ $t("circlePage.documents.description") }}
@@ -250,38 +281,11 @@ onMounted(async () => {
{{ $t("circlePage.documents.action") }}
-
+
diff --git a/client/src/services/files.ts b/client/src/services/files.ts
new file mode 100644
index 00000000..cb00bd5c
--- /dev/null
+++ b/client/src/services/files.ts
@@ -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
{
+ 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}/`);
+}
diff --git a/client/src/stores/courseSessions.ts b/client/src/stores/courseSessions.ts
index e541ae03..57133070 100644
--- a/client/src/stores/courseSessions.ts
+++ b/client/src/stores/courseSessions.ts
@@ -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([]);
+
+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,
};
+
});
diff --git a/client/src/types.ts b/client/src/types.ts
index 0626ea63..c88e017d 100644
--- a/client/src/types.ts
+++ b/client/src/types.ts
@@ -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;
+ };
+}
diff --git a/docs/file_uploads.md b/docs/file_uploads.md
new file mode 100644
index 00000000..d76310a4
--- /dev/null
+++ b/docs/file_uploads.md
@@ -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:::",
+ "arn:aws:s3:::/*"
+ ]
+ }
+ ]
+}
+```
diff --git a/env_secrets/local_chrigu.env b/env_secrets/local_chrigu.env
index 5012ac7d..324ec6b9 100644
Binary files a/env_secrets/local_chrigu.env and b/env_secrets/local_chrigu.env differ
diff --git a/server/config/settings/base.py b/server/config/settings/base.py
index 9c02b5f9..91e34203 100644
--- a/server/config/settings/base.py
+++ b/server/config/settings/base.py
@@ -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"
):
diff --git a/server/config/urls.py b/server/config/urls.py
index ddc6cd80..5dc6290e 100644
--- a/server/config/urls.py
+++ b/server/config/urls.py
@@ -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//', document_delete,
+ name='document_delete'),
+ path(r'api/core/file/finish', document_upload_finish,
+ name='file_upload_finish'),
+ path(r"api/core/document/local//", 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/')(
diff --git a/server/vbv_lernwelt/course/migrations/0008_circledocument.py b/server/vbv_lernwelt/course/migrations/0008_circledocument.py
new file mode 100644
index 00000000..261310f4
--- /dev/null
+++ b/server/vbv_lernwelt/course/migrations/0008_circledocument.py
@@ -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",
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/server/vbv_lernwelt/course/models.py b/server/vbv_lernwelt/course/models.py
index f730e8d9..2179f25f 100644
--- a/server/vbv_lernwelt/course/models.py
+++ b/server/vbv_lernwelt/course/models.py
@@ -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)
diff --git a/server/vbv_lernwelt/course/serializers.py b/server/vbv_lernwelt/course/serializers.py
index df9b5246..312f2c21 100644
--- a/server/vbv_lernwelt/course/serializers.py
+++ b/server/vbv_lernwelt/course/serializers.py
@@ -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()
diff --git a/server/vbv_lernwelt/course/views.py b/server/vbv_lernwelt/course/views.py
index 7cf09496..33de3ca0 100644
--- a/server/vbv_lernwelt/course/views.py
+++ b/server/vbv_lernwelt/course/views.py
@@ -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)
diff --git a/server/vbv_lernwelt/files/__init__.py b/server/vbv_lernwelt/files/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/vbv_lernwelt/files/enums.py b/server/vbv_lernwelt/files/enums.py
new file mode 100644
index 00000000..65c56f06
--- /dev/null
+++ b/server/vbv_lernwelt/files/enums.py
@@ -0,0 +1,11 @@
+from enum import Enum
+
+
+class FileUploadStrategy(Enum):
+ STANDARD = "standard"
+ DIRECT = "direct"
+
+
+class FileUploadStorage(Enum):
+ LOCAL = "local"
+ S3 = "s3"
diff --git a/server/vbv_lernwelt/files/integrations.py b/server/vbv_lernwelt/files/integrations.py
new file mode 100644
index 00000000..99180a0d
--- /dev/null
+++ b/server/vbv_lernwelt/files/integrations.py
@@ -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/.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': '',
+ },
+ '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,
+ )
diff --git a/server/vbv_lernwelt/files/migrations/0001_initial.py b/server/vbv_lernwelt/files/migrations/0001_initial.py
new file mode 100644
index 00000000..0f4f89df
--- /dev/null
+++ b/server/vbv_lernwelt/files/migrations/0001_initial.py
@@ -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,
+ ),
+ ),
+ ],
+ ),
+ ]
diff --git a/server/vbv_lernwelt/files/migrations/__init__.py b/server/vbv_lernwelt/files/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/server/vbv_lernwelt/files/mixins.py b/server/vbv_lernwelt/files/mixins.py
new file mode 100644
index 00000000..b63ced13
--- /dev/null
+++ b/server/vbv_lernwelt/files/mixins.py
@@ -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
+ 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,)
diff --git a/server/vbv_lernwelt/files/models.py b/server/vbv_lernwelt/files/models.py
new file mode 100644
index 00000000..2a3950f5
--- /dev/null
+++ b/server/vbv_lernwelt/files/models.py
@@ -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}"
diff --git a/server/vbv_lernwelt/files/services.py b/server/vbv_lernwelt/files/services.py
new file mode 100644
index 00000000..43ed8924
--- /dev/null
+++ b/server/vbv_lernwelt/files/services.py
@@ -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
diff --git a/server/vbv_lernwelt/files/utils.py b/server/vbv_lernwelt/files/utils.py
new file mode 100644
index 00000000..945d003f
--- /dev/null
+++ b/server/vbv_lernwelt/files/utils.py
@@ -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