Commit 1dbe45c7 authored by Andreas Jensen Jonassen's avatar Andreas Jensen Jonassen
Browse files

Merge branch 'feature/exercise-fileupload' into 'master'

Feature/exercise fileupload

See merge request !2
parents 7a5ed14f 1bb0c6f5
Pipeline #113667 passed with stages
in 11 minutes and 40 seconds
......@@ -2,3 +2,5 @@ backend/secfit/.vscode/
backend/secfit/*/migrations/__pycache__/
backend/secfit/*/__pycache__/
backend/secfit/db.sqlite3
ENV
......@@ -3,9 +3,10 @@
from django.contrib import admin
# Register your models here.
from .models import Exercise, ExerciseInstance, Workout, WorkoutFile
from .models import Exercise, ExerciseInstance, Workout, WorkoutFile, ExerciseFile
admin.site.register(Exercise)
admin.site.register(ExerciseInstance)
admin.site.register(ExerciseFile)
admin.site.register(Workout)
admin.site.register(WorkoutFile)
# Generated by Django 3.1 on 2021-02-23 12:05
from django.conf import settings
from django.db import migrations, models
import django.db.models.deletion
import workouts.models
class Migration(migrations.Migration):
dependencies = [
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('workouts', '0003_rememberme'),
]
operations = [
migrations.CreateModel(
name='ExerciseFile',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('file', models.FileField(upload_to=workouts.models.exercise_directory_path)),
('exercise', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='workouts.exercise')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='exercise_files', to=settings.AUTH_USER_MODEL)),
],
),
]
# Generated by Django 3.1 on 2021-02-23 14:45
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('workouts', '0004_exercisefile'),
]
operations = [
migrations.RemoveField(
model_name='exercisefile',
name='owner',
),
]
......@@ -110,6 +110,29 @@ class ExerciseInstance(models.Model):
sets = models.IntegerField()
number = models.IntegerField()
def exercise_directory_path(instance, filename):
"""Return path for which exercise files should be uploaded on the web server
Args:
instance (ExerciseFile): ExerciseFile instance
filename (str): Name of the file
Returns:
str: Path where exercise file is stored
"""
return f"exercises/{instance.exercise.id}/{filename}"
class ExerciseFile(models.Model):
"""Django model for file associated with a exercise. Basically a wrapper.
Attributes:
exercise: The exercise for which this file has been uploaded
owner: The user who uploaded the file
file: The actual file that's being uploaded
"""
exercise = models.ForeignKey(Exercise, on_delete=models.CASCADE, related_name="files")
file = models.FileField(upload_to=exercise_directory_path)
def workout_directory_path(instance, filename):
"""Return path for which workout files should be uploaded on the web server
......
......@@ -2,7 +2,7 @@
"""
from rest_framework import serializers
from rest_framework.serializers import HyperlinkedRelatedField
from workouts.models import Workout, Exercise, ExerciseInstance, WorkoutFile, RememberMe
from workouts.models import Workout, Exercise, ExerciseInstance, ExerciseFile, WorkoutFile, RememberMe
class ExerciseInstanceSerializer(serializers.HyperlinkedModelSerializer):
......@@ -197,6 +197,27 @@ class WorkoutSerializer(serializers.HyperlinkedModelSerializer):
"""
return obj.owner.username
class ExerciseFileSerializer(serializers.HyperlinkedModelSerializer):
"""Serializer for a ExerciseFile. Hyperlinks are used for relationships by default.
Serialized fields: url, id, owner, file, exercise
Attributes:
owner: The owner (User) of the ExerciseFile, represented by a username. ReadOnly
exercise: The associate exercise for this ExerciseFile, represented by a hyperlink
"""
owner = serializers.ReadOnlyField(source="owner.username")
exercise = HyperlinkedRelatedField(
queryset=Exercise.objects.all(), view_name="exercise-detail", required=False
)
class Meta:
model = ExerciseFile
fields = ["url", "id", "owner", "file", "exercise"]
def create(self, validated_data):
return ExerciseFile.objects.create(**validated_data)
class ExerciseSerializer(serializers.HyperlinkedModelSerializer):
"""Serializer for an Exercise. Hyperlinks are used for relationships by default.
......@@ -210,10 +231,68 @@ class ExerciseSerializer(serializers.HyperlinkedModelSerializer):
instances = serializers.HyperlinkedRelatedField(
many=True, view_name="exerciseinstance-detail", read_only=True
)
files = ExerciseFileSerializer(many=True, required=False)
class Meta:
model = Exercise
fields = ["url", "id", "name", "description", "unit", "instances"]
fields = [
"url",
"id",
"name",
"description",
"unit",
"instances",
"files"
]
def create(self, validated_data):
files_data = []
if "files" in validated_data:
files_data = validated_data.pop("files")
exercise = Exercise.objects.create(**validated_data)
for file_data in files_data:
ExerciseFile.objects.create(
exercise=exercise, file=file_data.get("file")
)
return exercise
def update(self, instance, validated_data):
instance.name = validated_data.get("name", instance.name)
instance.description = validated_data.get("description", instance.description)
instance.unit = validated_data.get("unit", instance.unit)
instance.save()
# Handle ExerciseFiles
if "files" in validated_data:
files_data = validated_data.pop("files")
files = instance.files
# Delete old files
files.all().delete()
# Add new files
for i in range(len(files.all()), len(files_data)):
ExerciseFile.objects.create(
exercise=instance,
file=files_data[i].get("file"),
)
return instance
def get_owner_username(self, obj):
"""Returns the owning user's username
Args:
obj (Exercise): Current Exercise
Returns:
str: Username of owner
"""
return obj.owner.username
class RememberMeSerializer(serializers.HyperlinkedModelSerializer):
......
......@@ -42,6 +42,16 @@ urlpatterns = format_suffix_patterns(
views.WorkoutFileDetail.as_view(),
name="workoutfile-detail",
),
path(
"api/exercise-files/",
views.ExerciseFileList.as_view(),
name="exercise-file-list",
),
path(
"api/exercise-files/<int:pk>/",
views.ExerciseFileDetail.as_view(),
name="exercisefile-detail",
),
path("", include("users.urls")),
path("", include("comments.urls")),
path("api/auth/", include("rest_framework.urls")),
......
......@@ -22,8 +22,8 @@ from workouts.permissions import (
IsWorkoutPublic,
)
from workouts.mixins import CreateListModelMixin
from workouts.models import Workout, Exercise, ExerciseInstance, WorkoutFile
from workouts.serializers import WorkoutSerializer, ExerciseSerializer
from workouts.models import Workout, Exercise, ExerciseInstance, ExerciseFile, WorkoutFile
from workouts.serializers import WorkoutSerializer, ExerciseSerializer, ExerciseFileSerializer
from workouts.serializers import RememberMeSerializer
from workouts.serializers import ExerciseInstanceSerializer, WorkoutFileSerializer
from django.core.exceptions import PermissionDenied
......@@ -48,6 +48,9 @@ def api_root(request, format=None):
"workout-files": reverse(
"workout-file-list", request=request, format=format
),
"exercise-files": reverse(
"exercise-file-list", request=request, format=format
),
"comments": reverse("comment-list", request=request, format=format),
"likes": reverse("like-list", request=request, format=format),
}
......@@ -186,6 +189,10 @@ class ExerciseList(
queryset = Exercise.objects.all()
serializer_class = ExerciseSerializer
permission_classes = [permissions.IsAuthenticated]
parser_classes = [
MultipartJsonParser,
JSONParser,
] # For parsing JSON and Multi-part requests
def get(self, request, *args, **kwargs):
return self.list(request, *args, **kwargs)
......@@ -208,6 +215,10 @@ class ExerciseDetail(
queryset = Exercise.objects.all()
serializer_class = ExerciseSerializer
permission_classes = [permissions.IsAuthenticated]
parser_classes = [
MultipartJsonParser,
JSONParser,
] # For parsing JSON and Multi-part requests
def get(self, request, *args, **kwargs):
return self.retrieve(request, *args, **kwargs)
......@@ -316,7 +327,6 @@ class WorkoutFileList(
return qs
class WorkoutFileDetail(
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
......@@ -340,3 +350,48 @@ class WorkoutFileDetail(
def delete(self, request, *args, **kwargs):
return self.destroy(request, *args, **kwargs)
class ExerciseFileList(
mixins.ListModelMixin,
mixins.CreateModelMixin,
CreateListModelMixin,
generics.GenericAPIView,
):
queryset = ExerciseFile.objects.all()
serializer_class = ExerciseFileSerializer
permission_classes = [permissions.IsAuthenticated]
parser_classes = [MultipartJsonParser, JSONParser]
def get(self, request, *args, **kwargs):
return self.list(request, *args, **kwargs)
def post(self, request, *args, **kwargs):
return self.create(request, *args, **kwargs)
def perform_create(self, serializer):
serializer.save(owner=self.request.user)
def get_queryset(self):
qs = ExerciseFile.objects.none()
if self.request.user:
qs = ExerciseFile.objects.distinct()
return qs
class ExerciseFileDetail(
mixins.RetrieveModelMixin,
mixins.UpdateModelMixin,
mixins.DestroyModelMixin,
generics.GenericAPIView,
):
queryset = ExerciseFile.objects.all()
serializer_class = ExerciseFileSerializer
permission_classes = [permissions.IsAuthenticated]
def get(self, request, *args, **kwargs):
return self.retrieve(request, *args, **kwargs)
def delete(self, request, *args, **kwargs):
return self.destroy(request, *args, **kwargs)
......@@ -35,6 +35,14 @@
<input type="text" class="form-control" id="inputUnit" name="unit" readonly>
</div>
<div class="col-lg-6"></div>
<div class="col-lg-6">
<label for="inputFile" class="form-label">Illustration</label>
<input type="file" class="form-control hide" id="inputFile" name="files" accept="image/x-png,image/gif,image/jpeg" multiple="multiple" readonly>
<div id="illustrations" class="row hide" style="margin: 0; gap: 10px;">
No illustrations.
</div>
</div>
<div class="col-lg-6"></div>
<div class="col-lg-6">
<input type="button" class="btn btn-primary hide" id="btn-ok-exercise" value=" OK ">
<input type="button" class="btn btn-primary" id="btn-edit-exercise" value=" Edit ">
......
......@@ -3,14 +3,45 @@ let okButton;
let deleteButton;
let editButton;
let oldFormData;
let oldImages;
let fileInput;
let fileInputPreview;
let illustrations;
function addIllustrations(files) {
let inner = "";
if (files.length === 0) {
inner = "No illustrations.";
}
illustrations.innerHTML = inner;
for(let file of files) {
new_link = document.createElement("a");
new_link.setAttribute('href', file.file)
new_link.setAttribute('target', '_blank')
new_link.style.padding = "0";
new_link.classList.add("col-auto");
//new_link.style.maxHeight = "150px";
new_link.style.objectFit = "contain";
new_link.style.webkitBoxShadow = "0 0.0625em 0.125em rgba(0,0,0,0.15)";
new_link.style.mozBoxShadow = "0 0.0625em 0.125em rgba(0,0,0,0.15)";
new_link.style.WebkitBoxShadow = "0 0.0625em 0.125em rgba(0,0,0,0.15)";
new_image = document.createElement("img");
new_image.setAttribute('src', file.file);
new_image.style.maxHeight = "150px";
new_link.appendChild(new_image);
illustrations.appendChild(new_link);
}
}
function handleCancelButtonDuringEdit() {
setReadOnly(true, "#form-exercise");
okButton.className += " hide";
deleteButton.className += " hide";
cancelButton.className += " hide";
editButton.className = editButton.className.replace(" hide", "");
okButton.classList.add('hide')
deleteButton.classList.add('hide')
cancelButton.classList.add('hide')
editButton.classList.remove('hide')
fileInput.classList.add('hide')
cancelButton.removeEventListener("click", handleCancelButtonDuringEdit);
......@@ -18,11 +49,15 @@ function handleCancelButtonDuringEdit() {
if (oldFormData.has("name")) form.name.value = oldFormData.get("name");
if (oldFormData.has("description")) form.description.value = oldFormData.get("description");
if (oldFormData.has("unit")) form.unit.value = oldFormData.get("unit");
if (oldFormData.has("files")) {
addIllustrations(oldImages);
illustrations.classList.remove("hide");
}
oldFormData.delete("name");
oldFormData.delete("description");
oldFormData.delete("unit");
oldFormData.delete("files");
}
function handleCancelButtonDuringCreate() {
......@@ -32,11 +67,17 @@ function handleCancelButtonDuringCreate() {
async function createExercise() {
let form = document.querySelector("#form-exercise");
let formData = new FormData(form);
let body = {"name": formData.get("name"),
"description": formData.get("description"),
"unit": formData.get("unit")};
let submitForm = new FormData();
submitForm.append("name", formData.get("name"));
submitForm.append("description", formData.get("description"));
submitForm.append("unit", formData.get("unit"));
let response = await sendRequest("POST", `${HOST}/api/exercises/`, body);
for (let file of formData.getAll("files")) {
submitForm.append("files", file);
}
let response = await sendRequest("POST", `${HOST}/api/exercises/`, submitForm, "");
if (response.ok) {
showToast("Success", {detail: "Successfully created exercise", type: "success"});
......@@ -50,10 +91,12 @@ async function createExercise() {
function handleEditExerciseButtonClick() {
setReadOnly(false, "#form-exercise");
editButton.className += " hide";
okButton.className = okButton.className.replace(" hide", "");
cancelButton.className = cancelButton.className.replace(" hide", "");
deleteButton.className = deleteButton.className.replace(" hide", "");
editButton.classList.add('hide');
okButton.classList.remove('hide');
cancelButton.classList.remove('hide');
deleteButton.classList.remove('hide');
fileInput.classList.remove('hide');
illustrations.classList.add('hide');
cancelButton.addEventListener("click", handleCancelButtonDuringEdit);
......@@ -84,39 +127,62 @@ async function retrieveExercise(id) {
let formData = new FormData(form);
for (let key of formData.keys()) {
if (key === "files") {
if(exerciseData[key] !== undefined) {
oldImages = exerciseData[key];
addIllustrations(exerciseData[key]);
}
continue;
}
let selector = `input[name="${key}"], textarea[name="${key}"]`;
let input = form.querySelector(selector);
let newVal = exerciseData[key];
input.value = newVal;
}
fileInput.classList.add('hide');
illustrations.classList.remove('hide');
}
}
async function updateExercise(id) {
let form = document.querySelector("#form-exercise");
let formData = new FormData(form);
let body = {"name": formData.get("name"),
"description": formData.get("description"),
"unit": formData.get("unit")};
let response = await sendRequest("PUT", `${HOST}/api/exercises/${id}/`, body);
let submitForm = new FormData();
submitForm.append("name", formData.get("name"));
submitForm.append("description", formData.get("description"));
submitForm.append("unit", formData.get("unit"));
for (let file of formData.getAll("files")) {
submitForm.append("files", file);
}
let response = await sendRequest("PUT", `${HOST}/api/exercises/${id}/`, submitForm, "");
if (!response.ok) {
let data = await response.json();
showToast(`Could not update exercise ${id}`, {...data, type: "danger"});
} else {
let response_data = await response.json();
// duplicate code from handleCancelButtonDuringEdit
// you should refactor this
setReadOnly(true, "#form-exercise");
okButton.className += " hide";
deleteButton.className += " hide";
cancelButton.className += " hide";
editButton.className = editButton.className.replace(" hide", "");
okButton.classList.add('hide');
deleteButton.classList.add('hide');
cancelButton.classList.add('hide');
editButton.classList.remove('hide');
cancelButton.removeEventListener("click", handleCancelButtonDuringEdit);
oldFormData.delete("name");
oldFormData.delete("description");
oldFormData.delete("unit");
oldFormData.delete("files");
oldImages = response_data["files"];
addIllustrations(oldImages);
fileInput.classList.add('hide');
illustrations.classList.remove('hide');
showToast("Success", {detail: "Successfully updated exercise", type: "success"});
}
}
......@@ -127,6 +193,8 @@ window.addEventListener("DOMContentLoaded", async () => {
deleteButton = document.querySelector("#btn-delete-exercise");
editButton = document.querySelector("#btn-edit-exercise");
oldFormData = null;
fileInput = document.querySelector("#inputFile");
illustrations = document.querySelector("#illustrations");
const urlParams = new URLSearchParams(window.location.search);
......@@ -142,10 +210,11 @@ window.addEventListener("DOMContentLoaded", async () => {
//create
else {
setReadOnly(false, "#form-exercise");
fileInput.classList.remove('hide');
editButton.className += " hide";
okButton.className = okButton.className.replace(" hide", "");
cancelButton.className = cancelButton.className.replace(" hide", "");
editButton.classList.add('hide');
okButton.classList.remove('hide');
cancelButton.classList.remove('hide');
okButton.addEventListener("click", async () => await createExercise());
cancelButton.addEventListener("click", handleCancelButtonDuringCreate);
......
Supports Markdown
0% or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment