diff --git a/.gitignore b/.gitignore index 420718a9278aa43b1af6276b536555094ad29f05..1e66904fa1b09c37d6d3c134813ba3ffcafe522b 100644 --- a/.gitignore +++ b/.gitignore @@ -5,4 +5,5 @@ backend/secfit/db.sqlite3 backend/secfit/htmlcov/ backend/secfit/.coverage backend/secfit/.coveragerc +.vscode .idea diff --git a/backend/secfit/workouts/models.py b/backend/secfit/workouts/models.py index 0f6214f2d9919d17fe68e93416a183a52209bda6..e5174c4aa328250f9fe484f6da29cbb6df36d9c5 100644 --- a/backend/secfit/workouts/models.py +++ b/backend/secfit/workouts/models.py @@ -2,30 +2,12 @@ log workouts (Workout), which contain instances (ExerciseInstance) of various type of exercises (Exercise). The user can also upload files (WorkoutFile) . """ -import os from django.db import models -from django.core.files.storage import FileSystemStorage -from django.conf import settings from django.contrib.auth import get_user_model - -class OverwriteStorage(FileSystemStorage): - """Filesystem storage for overwriting files. Currently unused.""" - - def get_available_name(self, name, max_length=None): - """https://djangosnippets.org/snippets/976/ - Returns a filename that's free on the target storage system, and - available for new content to be written to. - - Args: - name (str): Name of the file - max_length (int, optional): Maximum length of a file name. Defaults to None. - """ - if self.exists(name): - os.remove(os.path.join(settings.MEDIA_ROOT, name)) +# Create your models here. -# Create your models here. class Workout(models.Model): """Django model for a workout that users can log. @@ -48,24 +30,29 @@ class Workout(models.Model): ) # Visibility levels - PUBLIC = "PU" # Visible to all authenticated users - COACH = "CO" # Visible only to owner and their coach - PRIVATE = "PR" # Visible only to owner + # Visible to all authenticated users + PUBLIC = "PU" + # Visible only to owner and their coach + COACH = "CO" + # Visible only to owner + PRIVATE = "PR" + # Choices for visibility level VISIBILITY_CHOICES = [ (PUBLIC, "Public"), (COACH, "Coach"), (PRIVATE, "Private"), - ] # Choices for visibility level + ] visibility = models.CharField( max_length=2, choices=VISIBILITY_CHOICES, default=COACH ) class Meta: + """Orders the object by date descendingly""" ordering = ["-date"] def __str__(self): - return self.name + return str(self.name) class Exercise(models.Model): @@ -90,7 +77,7 @@ class Exercise(models.Model): unit = models.CharField(max_length=50) def __str__(self): - return self.name + return str(self.name) class ExerciseInstance(models.Model): @@ -139,7 +126,8 @@ class WorkoutFile(models.Model): file: The actual file that's being uploaded """ - workout = models.ForeignKey(Workout, on_delete=models.CASCADE, related_name="files") + workout = models.ForeignKey( + Workout, on_delete=models.CASCADE, related_name="files") owner = models.ForeignKey( get_user_model(), on_delete=models.CASCADE, related_name="workout_files" ) @@ -156,4 +144,4 @@ class RememberMe(models.Model): remember_me = models.CharField(max_length=500) def __str__(self): - return self.remember_me + return str(self.remember_me) diff --git a/backend/secfit/workouts/serializers.py b/backend/secfit/workouts/serializers.py index 6abbe31ffd71c5e9cbba140e34a03176b127a4bf..14ccaf6f29ca2b71452a97d283ebaa8e7e755ba0 100644 --- a/backend/secfit/workouts/serializers.py +++ b/backend/secfit/workouts/serializers.py @@ -19,6 +19,7 @@ class ExerciseInstanceSerializer(serializers.HyperlinkedModelSerializer): ) class Meta: + """serialized fields for the model ExerciseInstance""" model = ExerciseInstance fields = ["url", "id", "exercise", "sets", "number", "workout"] @@ -39,6 +40,7 @@ class WorkoutFileSerializer(serializers.HyperlinkedModelSerializer): ) class Meta: + """serialized fields for the model WorkoutFile""" model = WorkoutFile fields = ["url", "id", "owner", "file", "workout"] @@ -66,6 +68,7 @@ class WorkoutSerializer(serializers.HyperlinkedModelSerializer): files = WorkoutFileSerializer(many=True, required=False) class Meta: + """serialized fields for the Workout. Owner is read only and cant be modified""" model = Workout fields = [ "url", @@ -101,10 +104,12 @@ class WorkoutSerializer(serializers.HyperlinkedModelSerializer): workout = Workout.objects.create(**validated_data) for exercise_instance_data in exercise_instances_data: - ExerciseInstance.objects.create(workout=workout, **exercise_instance_data) + ExerciseInstance.objects.create( + workout=workout, **exercise_instance_data) for file_data in files_data: WorkoutFile.objects.create( - workout=workout, owner=workout.owner, file=file_data.get("file") + workout=workout, owner=workout.owner, file=file_data.get( + "file") ) return workout @@ -122,67 +127,17 @@ class WorkoutSerializer(serializers.HyperlinkedModelSerializer): Returns: Workout: Updated Workout instance """ - exercise_instances_data = validated_data.pop("exercise_instances") - exercise_instances = instance.exercise_instances instance.name = validated_data.get("name", instance.name) instance.notes = validated_data.get("notes", instance.notes) - instance.visibility = validated_data.get("visibility", instance.visibility) + instance.visibility = validated_data.get( + "visibility", instance.visibility) instance.date = validated_data.get("date", instance.date) instance.save() - # Handle ExerciseInstances + handle_exercise_instance(validated_data, instance) - # This updates existing exercise instances without adding or deleting object. - # zip() will yield n 2-tuples, where n is - # min(len(exercise_instance), len(exercise_instance_data)) - for exercise_instance, exercise_instance_data in zip( - exercise_instances.all(), exercise_instances_data - ): - exercise_instance.exercise = exercise_instance_data.get( - "exercise", exercise_instance.exercise - ) - exercise_instance.number = exercise_instance_data.get( - "number", exercise_instance.number - ) - exercise_instance.sets = exercise_instance_data.get( - "sets", exercise_instance.sets - ) - exercise_instance.save() - - # If new exercise instances have been added to the workout, then create them - if len(exercise_instances_data) > len(exercise_instances.all()): - for i in range(len(exercise_instances.all()), len(exercise_instances_data)): - exercise_instance_data = exercise_instances_data[i] - ExerciseInstance.objects.create( - workout=instance, **exercise_instance_data - ) - # Else if exercise instances have been removed from the workout, then delete them - elif len(exercise_instances_data) < len(exercise_instances.all()): - for i in range(len(exercise_instances_data), len(exercise_instances.all())): - exercise_instances.all()[i].delete() - - # Handle WorkoutFiles - - if "files" in validated_data: - files_data = validated_data.pop("files") - files = instance.files - - for file, file_data in zip(files.all(), files_data): - file.file = file_data.get("file", file.file) - - # If new files have been added, creating new WorkoutFiles - if len(files_data) > len(files.all()): - for i in range(len(files.all()), len(files_data)): - WorkoutFile.objects.create( - workout=instance, - owner=instance.owner, - file=files_data[i].get("file"), - ) - # Else if files have been removed, delete WorkoutFiles - elif len(files_data) < len(files.all()): - for i in range(len(files_data), len(files.all())): - files.all()[i].delete() + handle_workout_files(validated_data, instance) return instance @@ -198,6 +153,64 @@ class WorkoutSerializer(serializers.HyperlinkedModelSerializer): return obj.owner.username +def handle_exercise_instance(validated_data, instance): + """This updates existing exercise instances without adding or deleting object. + zip() will yield n 2-tuples, where n is + min(len(exercise_instance), len(exercise_instance_data)) + """ + exercise_instances_data = validated_data.pop("exercise_instances") + exercise_instances = instance.exercise_instances + + for exercise_instance, exercise_instance_data in zip( + exercise_instances.all(), exercise_instances_data + ): + exercise_instance.exercise = exercise_instance_data.get( + "exercise", exercise_instance.exercise + ) + exercise_instance.number = exercise_instance_data.get( + "number", exercise_instance.number + ) + exercise_instance.sets = exercise_instance_data.get( + "sets", exercise_instance.sets + ) + exercise_instance.save() + + # If new exercise instances have been added to the workout, then create them + if len(exercise_instances_data) > len(exercise_instances.all()): + for i in range(len(exercise_instances.all()), len(exercise_instances_data)): + exercise_instance_data = exercise_instances_data[i] + ExerciseInstance.objects.create( + workout=instance, **exercise_instance_data + ) + # Else if exercise instances have been removed from the workout, then delete them + elif len(exercise_instances_data) < len(exercise_instances.all()): + for i in range(len(exercise_instances_data), len(exercise_instances.all())): + exercise_instances.all()[i].delete() + + +def handle_workout_files(validated_data, instance): + """Handles the updating of exercises in a workout instance.""" + if "files" in validated_data: + files_data = validated_data.pop("files") + files = instance.files + + for file, file_data in zip(files.all(), files_data): + file.file = file_data.get("file", file.file) + + # If new files have been added, creating new WorkoutFiles + if len(files_data) > len(files.all()): + for i in range(len(files.all()), len(files_data)): + WorkoutFile.objects.create( + workout=instance, + owner=instance.owner, + file=files_data[i].get("file"), + ) + # Else if files have been removed, delete WorkoutFiles + elif len(files_data) < len(files.all()): + for i in range(len(files_data), len(files.all())): + files.all()[i].delete() + + class ExerciseSerializer(serializers.HyperlinkedModelSerializer): """Serializer for an Exercise. Hyperlinks are used for relationships by default. @@ -212,8 +225,18 @@ class ExerciseSerializer(serializers.HyperlinkedModelSerializer): ) class Meta: + """serialized fields for the model Exercise""" model = Exercise - fields = ["url", "id", "name", "description", "duration", "calories", "muscleGroup", "unit", "instances"] + fields = [ + "url", + "id", + "name", + "description", + "duration", + "calories", + "muscleGroup", + "unit", + "instances"] class RememberMeSerializer(serializers.HyperlinkedModelSerializer): @@ -226,5 +249,6 @@ class RememberMeSerializer(serializers.HyperlinkedModelSerializer): """ class Meta: + """serialized fields for the model RememberMe""" model = RememberMe fields = ["remember_me"] diff --git a/backend/secfit/workouts/views.py b/backend/secfit/workouts/views.py index efddf40454376b23d233f9fe2cecaf9da43fddb8..2212458d90d12340df4de314ec7767597ba2cdef 100644 --- a/backend/secfit/workouts/views.py +++ b/backend/secfit/workouts/views.py @@ -1,16 +1,16 @@ """Contains views for the workouts application. These are mostly class-based views. """ -from rest_framework import generics, mixins -from rest_framework import permissions - -from rest_framework.parsers import ( - JSONParser, -) +import base64 +import pickle +from collections import namedtuple +from rest_framework import generics, mixins, permissions, filters +from rest_framework.parsers import JSONParser from rest_framework.decorators import api_view from rest_framework.response import Response from rest_framework.reverse import reverse from django.db.models import Q -from rest_framework import filters +from django.core.exceptions import PermissionDenied +from django.core.signing import Signer from workouts.parsers import MultipartJsonParser from workouts.permissions import ( IsOwner, @@ -23,20 +23,19 @@ from workouts.permissions import ( ) from workouts.mixins import CreateListModelMixin from workouts.models import Workout, Exercise, ExerciseInstance, WorkoutFile -from workouts.serializers import WorkoutSerializer, ExerciseSerializer -from workouts.serializers import RememberMeSerializer -from workouts.serializers import ExerciseInstanceSerializer, WorkoutFileSerializer -from django.core.exceptions import PermissionDenied +from workouts.serializers import ( + WorkoutSerializer, + ExerciseSerializer, + RememberMeSerializer, + ExerciseInstanceSerializer, + WorkoutFileSerializer +) from rest_framework_simplejwt.tokens import RefreshToken -from rest_framework.response import Response -import json -from collections import namedtuple -import base64, pickle -from django.core.signing import Signer @api_view(["GET"]) def api_root(request, format=None): + """The API root view""" return Response( { "users": reverse("user-list", request=request, format=format), @@ -54,13 +53,16 @@ def api_root(request, format=None): ) -# Allow users to save a persistent session in their browser class RememberMe( mixins.ListModelMixin, mixins.CreateModelMixin, mixins.DestroyModelMixin, generics.GenericAPIView, ): + """Allow users to save a persistent session in their browser. + + HTTP methods: GET, POST + """ serializer_class = RememberMeSerializer @@ -68,13 +70,13 @@ class RememberMe( if request.user.is_authenticated == False: raise PermissionDenied else: - return Response({"remember_me": self.rememberme()}) + return Response({"remember_me": self.remember_me()}) def post(self, request): - cookieObject = namedtuple("Cookies", request.COOKIES.keys())( + cookie_object = namedtuple("Cookies", request.COOKIES.keys())( *request.COOKIES.values() ) - user = self.get_user(cookieObject) + user = self.get_user(cookie_object) refresh = RefreshToken.for_user(user) return Response( { @@ -83,15 +85,15 @@ class RememberMe( } ) - def get_user(self, cookieObject): - decode = base64.b64decode(cookieObject.remember_me) + def get_user(self, cookie_object): + decode = base64.b64decode(cookie_object.remember_me) user, sign = pickle.loads(decode) # Validate signature if sign == self.sign_user(user): return user - def rememberme(self): + def remember_me(self): creds = [self.request.user, self.sign_user(str(self.request.user))] return base64.b64encode(pickle.dumps(creds)) @@ -111,13 +113,15 @@ class WorkoutList( """ serializer_class = WorkoutSerializer + # User must be authenticated to create/view workouts permission_classes = [ permissions.IsAuthenticated - ] # User must be authenticated to create/view workouts + ] + # For parsing JSON and Multi-part requests parser_classes = [ MultipartJsonParser, JSONParser, - ] # For parsing JSON and Multi-part requests + ] filter_backends = [filters.OrderingFilter] ordering_fields = ["name", "date", "owner__username"] @@ -131,18 +135,18 @@ class WorkoutList( serializer.save(owner=self.request.user) def get_queryset(self): - qs = Workout.objects.none() + queryset = Workout.objects.none() if self.request.user: # A workout should be visible to the requesting user if any of the following hold: # - The workout has public visibility # - The owner of the workout is the requesting user # - The workout has coach visibility and the requesting user is the owner's coach - qs = Workout.objects.filter( + queryset = Workout.objects.filter( Q(visibility="PU") | (Q(visibility="CO") & Q(owner__coach=self.request.user)) ).distinct() - return qs + return queryset class WorkoutDetail( @@ -228,7 +232,10 @@ class ExerciseInstanceList( CreateListModelMixin, generics.GenericAPIView, ): - """Class defining the web response for the creation""" + """Class defining the web response for the creation + + HTTP methods: GET, POST + """ serializer_class = ExerciseInstanceSerializer permission_classes = [permissions.IsAuthenticated & IsOwnerOfWorkout] @@ -259,6 +266,12 @@ class ExerciseInstanceDetail( mixins.DestroyModelMixin, generics.GenericAPIView, ): + """Class defining the web response for the modification, deletion and getting + exercise instance details. + + HTTP methods: GET, PUT, PATCH, DELETE + """ + serializer_class = ExerciseInstanceSerializer permission_classes = [ permissions.IsAuthenticated @@ -287,6 +300,10 @@ class WorkoutFileList( CreateListModelMixin, generics.GenericAPIView, ): + """Class defining the web response for the getting and creating list for workout files. + + HTTP methods: GET, POST + """ queryset = WorkoutFile.objects.all() serializer_class = WorkoutFileSerializer @@ -323,6 +340,10 @@ class WorkoutFileDetail( mixins.DestroyModelMixin, generics.GenericAPIView, ): + """Class defining the web response for the getting deleting workout file details. + + HTTP methods: GET, POST + """ queryset = WorkoutFile.objects.all() serializer_class = WorkoutFileSerializer diff --git a/frontend/www/scripts/exercise.js b/frontend/www/scripts/exercise.js index f845fe1844b633cf1b0bf1365eee4323c4c84bcc..1244091271b14ddc8796696d24c6892773ac4e92 100644 --- a/frontend/www/scripts/exercise.js +++ b/frontend/www/scripts/exercise.js @@ -31,49 +31,57 @@ class MuscleGroup { } } +function deleteFormData() { + setReadOnly(true, "#form-exercise"); + okButton.className += " hide"; + deleteButton.className += " hide"; + cancelButton.className += " hide"; + editButton.className = editButton.className.replace(" hide", ""); + + cancelButton.removeEventListener("click", handleCancelButtonDuringEdit); + + oldFormData.delete("name"); + oldFormData.delete("description"); + oldFormData.delete("duration"); + oldFormData.delete("calories"); + oldFormData.delete("muscleGroup"); + oldFormData.delete("unit"); +} + function handleCancelButtonDuringEdit() { - setReadOnly(true, "#form-exercise"); document.querySelector("select").setAttribute("disabled", "") - okButton.className += " hide"; - deleteButton.className += " hide"; - cancelButton.className += " hide"; - editButton.className = editButton.className.replace(" hide", ""); - - cancelButton.removeEventListener("click", handleCancelButtonDuringEdit); - let form = document.querySelector("#form-exercise"); + if (oldFormData.has("name")) form.name.value = oldFormData.get("name"); if (oldFormData.has("description")) form.description.value = oldFormData.get("description"); if (oldFormData.has("duration")) form.duration.value = oldFormData.get("duration"); if (oldFormData.has("calories")) form.calories.value = oldFormData.get("calories"); if (oldFormData.has("muscleGroup")) form.muscleGroup.value = oldFormData.get("muscleGroup"); if (oldFormData.has("unit")) form.unit.value = oldFormData.get("unit"); - - oldFormData.delete("name"); - oldFormData.delete("description"); - oldFormData.delete("duration"); - oldFormData.delete("calories"); - oldFormData.delete("muscleGroup"); - oldFormData.delete("unit"); + deleteFormData(); } function handleCancelButtonDuringCreate() { window.location.replace("exercises.html"); } +function addFormData(form) { + let formData = new FormData(form); + let selectedMuscleGroup = new MuscleGroup(formData.get("muscleGroup")); + let updatedMuscleGroup = selectedMuscleGroup.getMuscleGroupType() + return {"name": formData.get("name"), + "description": formData.get("description"), + "duration": formData.get("duration"), + "calories": formData.get("calories"), + "muscleGroup": updatedMuscleGroup ? updatedMuscleGroup : formData.get("muscleGroup"), + "unit": formData.get("unit")}; +} + async function createExercise() { document.querySelector("select").removeAttribute("disabled") let form = document.querySelector("#form-exercise"); - let formData = new FormData(form); - let body = {"name": formData.get("name"), - "description": formData.get("description"), - "duration": formData.get("duration"), - "calories": formData.get("calories"), - "muscleGroup": formData.get("muscleGroup"), - "unit": formData.get("unit")}; - - let response = await sendRequest("POST", `${HOST}/api/exercises/`, body); + let response = await sendRequest("POST", `${HOST}/api/exercises/`, addFormData(form)); if (response.ok) { window.location.replace("exercises.html"); @@ -139,20 +147,10 @@ async function retrieveExercise(id) { async function updateExercise(id) { let form = document.querySelector("#form-exercise"); - let formData = new FormData(form); - let muscleGroupSelector = document.querySelector("select") muscleGroupSelector.removeAttribute("disabled") - let selectedMuscleGroup = new MuscleGroup(formData.get("muscleGroup")); - - let body = {"name": formData.get("name"), - "description": formData.get("description"), - "duration": formData.get("duration"), - "calories": formData.get("calories"), - "muscleGroup": selectedMuscleGroup.getMuscleGroupType(), - "unit": formData.get("unit")}; - let response = await sendRequest("PUT", `${HOST}/api/exercises/${id}/`, body); + let response = await sendRequest("PUT", `${HOST}/api/exercises/${id}/`, addFormData(form)); if (!response.ok) { let data = await response.json(); @@ -160,22 +158,7 @@ async function updateExercise(id) { document.body.prepend(alert); } else { muscleGroupSelector.setAttribute("disabled", "") - // 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", ""); - - cancelButton.removeEventListener("click", handleCancelButtonDuringEdit); - - oldFormData.delete("name"); - oldFormData.delete("description"); - oldFormData.delete("duration"); - oldFormData.delete("calories"); - oldFormData.delete("muscleGroup"); - oldFormData.delete("unit"); + deleteFormData() } }