Skip to content

Commit

Permalink
WIP - Uploads jpeg file to folder on backend.
Browse files Browse the repository at this point in the history
Still needs to associated image with entity (org, in this case).
Still needs to add / display uploaded images in entitys UI.
  • Loading branch information
mattburnett-repo committed Feb 9, 2025
1 parent 57e8c5d commit a33bb53
Show file tree
Hide file tree
Showing 10 changed files with 101 additions and 103 deletions.
37 changes: 10 additions & 27 deletions backend/content/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from typing import Dict, Union

from django.utils.translation import gettext as _
from PIL import Image as PilImage
from rest_framework import serializers

from content.models import (
Expand Down Expand Up @@ -42,34 +41,18 @@ class Meta:
read_only_fields = ["id", "creation_date"]

def validate(self, data: Dict[str, Union[str, int]]) -> Dict[str, Union[str, int]]:
image_extensions = [".jpg", ".jpeg", ".png"]
img_format = ""

file_location = data["file_location"]
if isinstance(file_location, str):
try:
with PilImage.open(file_location) as img:
img.verify()
img_format = img.format.lower()

except Exception as e:
raise serializers.ValidationError(
_("The image is not valid."), code="corrupted_file"
) from e

if img_format not in image_extensions:
raise serializers.ValidationError(
_("The image must be in jpg, jpeg or png format."),
code="invalid_extension",
)

else:
raise serializers.ValidationError(
_("The file location must be a string."), code="invalid_file_location"
)

# Remove string validation since we're getting a file object
if "file_location" not in data:
raise serializers.ValidationError("No file was submitted.")
return data

def create(self, validated_data):
# Handle file upload properly
file_obj = self.context["request"].FILES.get("file_location")
if file_obj:
validated_data["file_location"] = file_obj
return super().create(validated_data)


class LocationSerializer(serializers.ModelSerializer[Location]):
class Meta:
Expand Down
1 change: 1 addition & 0 deletions backend/content/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@

router.register(prefix=r"discussions", viewset=views.DiscussionViewSet)
router.register(prefix=r"resources", viewset=views.ResourceViewSet)
router.register(prefix=r"images", viewset=views.ImageViewSet)

# MARK: Bridge Tables

Expand Down
20 changes: 19 additions & 1 deletion backend/content/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,16 @@
# mypy: disable-error-code="override"
from django.db.models import Q
from rest_framework import status, viewsets
from rest_framework.parsers import FormParser, MultiPartParser
from rest_framework.permissions import IsAuthenticatedOrReadOnly
from rest_framework.request import Request
from rest_framework.response import Response

from content.models import Discussion, DiscussionEntry, Resource
from content.models import Discussion, DiscussionEntry, Image, Resource
from content.serializers import (
DiscussionEntrySerializer,
DiscussionSerializer,
ImageSerializer,
ResourceSerializer,
)
from core.paginator import CustomPagination
Expand Down Expand Up @@ -297,3 +299,19 @@ def destroy(self, request: Request, pk: str | None = None) -> Response:
self.perform_destroy(item)

return Response(status=status.HTTP_204_NO_CONTENT)


class ImageViewSet(viewsets.ModelViewSet):
queryset = Image.objects.all()
serializer_class = ImageSerializer
parser_classes = (MultiPartParser, FormParser)

def create(self, request, *args, **kwargs):
serializer = self.get_serializer(
data=request.data,
context={"request": request}, # Pass request to serializer
)
if serializer.is_valid():
serializer.save()
return Response(serializer.data, status=status.HTTP_201_CREATED)
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
4 changes: 4 additions & 0 deletions backend/core/settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,10 @@
STATIC_ROOT = BASE_DIR / "static/"
STATIC_URL = "static/"

# After STATIC_URL setting
MEDIA_ROOT = BASE_DIR / "media"
MEDIA_URL = "/media/"

# MARK: Primary Key
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field

Expand Down
Binary file added backend/media/images/testImage_01.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added backend/media/images/testImage_02.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,8 @@ services:
interval: 10s
timeout: 5s
retries: 5
volumes:
- ./backend/media:/app/media

frontend:
env_file:
Expand Down
10 changes: 8 additions & 2 deletions frontend/components/modal/upload-images/ModalUploadImages.vue
Original file line number Diff line number Diff line change
Expand Up @@ -76,7 +76,7 @@
</template>
</draggable>
<BtnAction
@click="true"
@click="handleUpload"
:cta="true"
:label="$t(i18nMap.components.modal_upload_images.upload)"
fontSize="sm"
Expand All @@ -99,7 +99,7 @@ import draggable from "vuedraggable";
import { i18nMap } from "~/types/i18n-map";
import { IconMap } from "~/types/icon-map";

const { files, handleFiles, removeFile } = useFileManager();
const { files, handleFiles, removeFile, uploadFiles } = useFileManager();

export interface Props {
uploadLimit?: number;
Expand All @@ -110,4 +110,10 @@ withDefaults(defineProps<Props>(), {
});

const modalName = "ModalUploadImages";

const handleUpload = async () => {
await uploadFiles();
const modals = useModals();
modals.closeModal(modalName);
};
</script>
Original file line number Diff line number Diff line change
@@ -1,77 +1,30 @@
<!-- SPDX-License-Identifier: AGPL-3.0-or-later -->
<template>
<button
@drop.prevent="onDrop"
@dragenter.prevent="activate"
@dragover.prevent="activate"
@dragleave.prevent="deactivate"
class="card-style-base flex w-full rounded-lg"
:class="{
'bg-highlight': isActive,
'bg-layer-1': !isActive,
}"
<div
class="flex h-32 w-full cursor-pointer items-center justify-center rounded-md border-2 border-dashed border-primary-text bg-layer-0 p-4 text-center text-primary-text"
@dragenter.prevent="isDropZoneActive = true"
@dragleave.prevent="isDropZoneActive = false"
@dragover.prevent="isDropZoneActive = true"
@drop.prevent="
isDropZoneActive = false;
$emit('files-dropped', ($event.dataTransfer as DataTransfer)?.files);
"
@click="$refs.file.click()"
>
<label
for="file-input"
class="flex h-80 w-full cursor-pointer items-center justify-center"
>
<slot :isDropZoneActive="isActive"></slot>
<input
@change="onInputChange"
id="file-input"
class="hidden"
type="file"
multiple
/>
</label>
</button>
<input
ref="file"
type="file"
class="hidden"
accept="image/jpeg,image/png"
@change="
$emit('files-dropped', ($event.target as HTMLInputElement)?.files)
"
multiple
/>
<slot :isDropZoneActive="isDropZoneActive" />
</div>
</template>

<script setup lang="ts">
const emit = defineEmits(["files-dropped"]);

const isActive = ref(false);

let deactivateTimeoutKey: number | null = null;

const activate = () => {
isActive.value = true;
if (deactivateTimeoutKey) clearTimeout(deactivateTimeoutKey);
};

const deactivate = () => {
isActive.value = false;
deactivateTimeoutKey = window.setTimeout(() => {
isActive.value = false;
}, 50);
};

function onDrop(e: DragEvent) {
if (!e.dataTransfer) return;
emit("files-dropped", [...e.dataTransfer.files]);
}

function onInputChange(e: Event) {
const target = e.target as HTMLInputElement;
if (!target.files) return;
emit("files-dropped", [...target.files]);
}

function preventDefaults(e: Event) {
e.preventDefault();
}

const events = ["dragenter", "dragover", "dragleave", "drop"];

onMounted(() => {
events.forEach((eventName) => {
document.body.addEventListener(eventName, preventDefaults);
});
});

onUnmounted(() => {
events.forEach((eventName) => {
document.body.removeEventListener(eventName, preventDefaults);
});
});
const isDropZoneActive = ref(false);
</script>
37 changes: 34 additions & 3 deletions frontend/composables/useFileManager.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,41 @@
// SPDX-License-Identifier: AGPL-3.0-or-later
export default function useFileManager(initialFiles: File[] = []) {
const files = ref<UploadableFile[]>([]);
handleFiles(initialFiles); // add initial files
handleFiles(initialFiles);

async function uploadFiles() {
const formData = new FormData();
files.value.forEach((uploadableFile) => {
formData.append("file_location", uploadableFile.file);
});

console.log(...formData.entries());

try {
const response = await fetch(`${BASE_BACKEND_URL}/content/images/`, {
method: "POST",
body: formData,
headers: {
Authorization: `Token ${localStorage.getItem("accessToken")}`,
},
});
if (response.ok) {
const data = await response.json();
files.value = [];
return data;
}
} catch (error) {
console.error("Upload failed:", error);
}
}

function handleFiles(newFiles: File[]) {
const newUploadableFiles = [...newFiles]
const allowedTypes = ["image/jpeg", "image/png"];
const validFiles = [...newFiles].filter((file) =>
allowedTypes.includes(file.type)
);

const newUploadableFiles = validFiles
.map((file) => new UploadableFile(file))
.filter((file) => !fileExists(file.id));
files.value = [...files.value, ...newUploadableFiles];
Expand All @@ -16,7 +47,6 @@ export default function useFileManager(initialFiles: File[] = []) {

function removeFile(file: UploadableFile) {
const index = files.value.indexOf(file);

if (index > -1) {
files.value.splice(index, 1);
}
Expand All @@ -26,6 +56,7 @@ export default function useFileManager(initialFiles: File[] = []) {
files,
handleFiles,
removeFile,
uploadFiles,
};
}

Expand Down

0 comments on commit a33bb53

Please sign in to comment.