From 928238a176adaf691915e933e0311c48c7837542 Mon Sep 17 00:00:00 2001 From: Haakon Gunleiksrud <haakogun@stud.ntnu.no> Date: Wed, 20 Oct 2021 16:39:28 +0000 Subject: [PATCH] Feat/meal module --- backend/secfit/meals/__init__.py | 0 backend/secfit/meals/admin.py | 9 + backend/secfit/meals/apps.py | 13 ++ .../secfit/meals/migrations/0001_initial.py | 42 +++++ backend/secfit/meals/migrations/__init__.py | 0 backend/secfit/meals/mixins.py | 26 +++ backend/secfit/meals/models.py | 82 +++++++++ backend/secfit/meals/parsers.py | 38 +++++ backend/secfit/meals/permissions.py | 35 ++++ backend/secfit/meals/serializers.py | 139 +++++++++++++++ backend/secfit/meals/tests.py | 6 + backend/secfit/meals/urls.py | 31 ++++ backend/secfit/meals/views.py | 155 +++++++++++++++++ backend/secfit/secfit/settings.py | 1 + backend/secfit/secfit/urls.py | 1 + .../migrations/0004_auto_20211020_0950.py | 28 ++++ frontend/www/meal.html | 73 ++++++++ frontend/www/meals.html | 55 ++++++ frontend/www/scripts/exercise.js | 21 ++- frontend/www/scripts/meal.js | 158 ++++++++++++++++++ frontend/www/scripts/meals.js | 91 ++++++++++ frontend/www/scripts/navbar.js | 1 + frontend/www/scripts/scripts.js | 3 + 23 files changed, 1005 insertions(+), 3 deletions(-) create mode 100644 backend/secfit/meals/__init__.py create mode 100644 backend/secfit/meals/admin.py create mode 100644 backend/secfit/meals/apps.py create mode 100644 backend/secfit/meals/migrations/0001_initial.py create mode 100644 backend/secfit/meals/migrations/__init__.py create mode 100644 backend/secfit/meals/mixins.py create mode 100644 backend/secfit/meals/models.py create mode 100644 backend/secfit/meals/parsers.py create mode 100644 backend/secfit/meals/permissions.py create mode 100644 backend/secfit/meals/serializers.py create mode 100644 backend/secfit/meals/tests.py create mode 100644 backend/secfit/meals/urls.py create mode 100644 backend/secfit/meals/views.py create mode 100644 backend/secfit/workouts/migrations/0004_auto_20211020_0950.py create mode 100644 frontend/www/meal.html create mode 100644 frontend/www/meals.html create mode 100644 frontend/www/scripts/meal.js create mode 100644 frontend/www/scripts/meals.js diff --git a/backend/secfit/meals/__init__.py b/backend/secfit/meals/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/secfit/meals/admin.py b/backend/secfit/meals/admin.py new file mode 100644 index 0000000..7d1abcc --- /dev/null +++ b/backend/secfit/meals/admin.py @@ -0,0 +1,9 @@ +"""Module for registering models from meals app to admin page so that they appear +""" +from django.contrib import admin + +# Register your models here. +from .models import Meal, MealFile + +admin.site.register(Meal) +admin.site.register(MealFile) diff --git a/backend/secfit/meals/apps.py b/backend/secfit/meals/apps.py new file mode 100644 index 0000000..2268b89 --- /dev/null +++ b/backend/secfit/meals/apps.py @@ -0,0 +1,13 @@ +"""AppConfig for meals app +""" +from django.apps import AppConfig + + +class MealsConfig(AppConfig): + """AppConfig for meals app + + Attributes: + name (str): The name of the application + """ + + name = "meals" diff --git a/backend/secfit/meals/migrations/0001_initial.py b/backend/secfit/meals/migrations/0001_initial.py new file mode 100644 index 0000000..2247d71 --- /dev/null +++ b/backend/secfit/meals/migrations/0001_initial.py @@ -0,0 +1,42 @@ +# Generated by Django 3.1 on 2021-10-20 15:17 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import meals.models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='Meal', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=100)), + ('date', models.DateTimeField()), + ('notes', models.TextField()), + ('calories', models.IntegerField()), + ('is_veg', models.BooleanField(default=False)), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meals', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['-date'], + }, + ), + migrations.CreateModel( + name='MealFile', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('file', models.FileField(upload_to=meals.models.meal_directory_path)), + ('meal', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='files', to='meals.meal')), + ('owner', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meal_files', to=settings.AUTH_USER_MODEL)), + ], + ), + ] diff --git a/backend/secfit/meals/migrations/__init__.py b/backend/secfit/meals/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/secfit/meals/mixins.py b/backend/secfit/meals/mixins.py new file mode 100644 index 0000000..e175c8d --- /dev/null +++ b/backend/secfit/meals/mixins.py @@ -0,0 +1,26 @@ +""" +Mixins for the meals application +""" + + +class CreateListModelMixin(object): + """Mixin that allows to create multiple objects from lists. + Taken from https://stackoverflow.com/a/48885641 + """ + + def get_serializer(self, *args, **kwargs): + """If an array is passed, set serializer to many. + + kwargs["many"] will be set to true if an array is passed. This argument + is passed when retrieving the serializer. + + Args: + *args: Variable length argument list passed to the serializer. + **kwargs: Arbitrary keyword arguments passed to the serializer, including "many". + + Returns: + [type]: [description] + """ + if isinstance(kwargs.get("data", {}), list): + kwargs["many"] = True + return super(CreateListModelMixin, self).get_serializer(*args, **kwargs) diff --git a/backend/secfit/meals/models.py b/backend/secfit/meals/models.py new file mode 100644 index 0000000..26e6f0b --- /dev/null +++ b/backend/secfit/meals/models.py @@ -0,0 +1,82 @@ +"""Contains the models for the meals Django application. Users +log meals (Meal). The user can also upload files (MealFile) . +""" +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. +class Meal(models.Model): + """Django model for a meal that users can log. + + A meal has several attributes, and files uploaded by the user. + + Attributes: + name: Name of the meal + date: Date and time the meal was consumed + notes: Notes about the meal + calories: Total amount of calories in the meal + is_veg: Whether the meal was vegetarian or not + owner: User that logged the meal + """ + + name = models.CharField(max_length=100) + date = models.DateTimeField() + notes = models.TextField() + calories = models.IntegerField() + is_veg = models.BooleanField(default=False) + owner = models.ForeignKey( + get_user_model(), on_delete=models.CASCADE, related_name="meals" + ) + + class Meta: + ordering = ["-date"] + + def __str__(self): + return self.name + +def meal_directory_path(instance, filename): + """Return path for which meal files should be uploaded on the web server + + Args: + instance (MealFile): MealFile instance + filename (str): Name of the file + + Returns: + str: Path where workout file is stored + """ + return f"meals/{instance.meal.id}/{filename}" + +class MealFile(models.Model): + """Django model for file associated with a meal. Basically a wrapper. + + Attributes: + meal: The meal for which this file has been uploaded + owner: The user who uploaded the file + file: The actual file that's being uploaded + """ + + meal = models.ForeignKey(Meal, on_delete=models.CASCADE, related_name="files") + owner = models.ForeignKey( + get_user_model(), on_delete=models.CASCADE, related_name="meal_files" + ) + file = models.FileField(upload_to=meal_directory_path) diff --git a/backend/secfit/meals/parsers.py b/backend/secfit/meals/parsers.py new file mode 100644 index 0000000..cf1646b --- /dev/null +++ b/backend/secfit/meals/parsers.py @@ -0,0 +1,38 @@ +"""Contains custom parsers for serializers from the meals Django app +""" +import json +from rest_framework import parsers + +# Thanks to https://stackoverflow.com/a/50514630 +class MultipartJsonParser(parsers.MultiPartParser): + """Parser for serializing multipart data containing both files and JSON. + + This is currently unused. + """ + + def parse(self, stream, media_type=None, parser_context=None): + result = super().parse( + stream, media_type=media_type, parser_context=parser_context + ) + data = {} + new_files = {"files": []} + + # for case1 with nested serializers + # parse each field with json + for key, value in result.data.items(): + if not isinstance(value, str): + data[key] = value + continue + if "{" in value or "[" in value: + try: + data[key] = json.loads(value) + except ValueError: + data[key] = value + else: + data[key] = value + + files = result.files.getlist("files") + for file in files: + new_files["files"].append({"file": file}) + + return parsers.DataAndFiles(data, new_files) diff --git a/backend/secfit/meals/permissions.py b/backend/secfit/meals/permissions.py new file mode 100644 index 0000000..9a43a52 --- /dev/null +++ b/backend/secfit/meals/permissions.py @@ -0,0 +1,35 @@ +"""Contains custom DRF permissions classes for the meals app +""" +from rest_framework import permissions +from meals.models import Meal + + +class IsOwner(permissions.BasePermission): + """Checks whether the requesting user is also the owner of the existing object""" + + def has_object_permission(self, request, view, obj): + return obj.owner == request.user + + +class IsOwnerOfMeal(permissions.BasePermission): + """Checks whether the requesting user is also the owner of the new or existing object""" + + def has_permission(self, request, view): + if request.method == "POST": + if request.data.get("meal"): + meal_id = request.data["meal"].split("/")[-2] + meal = Meal.objects.get(pk=meal_id) + if meal: + return meal.owner == request.user + return False + + return True + + def has_object_permission(self, request, view, obj): + return obj.meal.owner == request.user + +class IsReadOnly(permissions.BasePermission): + """Checks whether the HTTP request verb is only for retrieving data (GET, HEAD, OPTIONS)""" + + def has_object_permission(self, request, view, obj): + return request.method in permissions.SAFE_METHODS diff --git a/backend/secfit/meals/serializers.py b/backend/secfit/meals/serializers.py new file mode 100644 index 0000000..adc327a --- /dev/null +++ b/backend/secfit/meals/serializers.py @@ -0,0 +1,139 @@ +"""Serializers for the meals application +""" +from rest_framework import serializers +from rest_framework.serializers import HyperlinkedRelatedField +from meals.models import Meal, MealFile + +class MealFileSerializer(serializers.HyperlinkedModelSerializer): + """Serializer for a MealFile. Hyperlinks are used for relationships by default. + + Serialized fields: url, id, owner, file, meal + + Attributes: + owner: The owner (User) of the MealFile, represented by a username. ReadOnly + meal: The associate meal for this MealFile, represented by a hyperlink + """ + + owner = serializers.ReadOnlyField(source="owner.username") + meal = HyperlinkedRelatedField( + queryset=Meal.objects.all(), view_name="meal-detail", required=False + ) + + class Meta: + model = MealFile + fields = ["url", "id", "owner", "file", "meal"] + + def create(self, validated_data): + return MealFile.objects.create(**validated_data) + + +class MealSerializer(serializers.HyperlinkedModelSerializer): + """Serializer for a Meal. Hyperlinks are used for relationships by default. + + This serializer specifies nested serialization since a meal consists of MealFiles. + + Serialized fields: url, id, name, date, notes, calories, owner, is_veg, owner_username, files + + Attributes: + owner_username: Username of the owning User + files: Serializer for MealFiles + """ + + owner_username = serializers.SerializerMethodField() + files = MealFileSerializer(many=True, required=False) + + class Meta: + model = Meal + fields = [ + "url", + "id", + "name", + "date", + "notes", + "calories", + "is_veg", + "owner", + "owner_username", + "files", + ] + extra_kwargs = {"owner": {"read_only": True}} + + def create(self, validated_data): + """Custom logic for creating MealFiles, and a Meal. + + This is needed to iterate over the files, since this serializer is nested. + + Args: + validated_data: Validated files + + Returns: + Meal: A newly created Meal + """ + files_data = [] + if "files" in validated_data: + files_data = validated_data.pop("files") + + meal = Meal.objects.create(**validated_data) + + for file_data in files_data: + MealFile.objects.create( + meal=meal, owner=meal.owner, file=file_data.get("file") + ) + + return meal + + def update(self, instance, validated_data): + """Custom logic for updating a Meal. + + This is needed because each object in files must be iterated + over and handled individually. + + Args: + instance (Meal): Current Meal object + validated_data: Contains data for validated fields + + Returns: + Meal: Updated Meal instance + """ + + instance.name = validated_data.get("name", instance.name) + instance.date = validated_data.get("date", instance.date) + instance.notes = validated_data.get("notes", instance.notes) + instance.is_veg = validated_data.get("is_veg", instance.is_veg) + instance.calories = validated_data.get("calories", instance.calories) + instance.save() + + # Handle MealFiles + + 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 MealFiles + if len(files_data) > len(files.all()): + for i in range(len(files.all()), len(files_data)): + MealFile.objects.create( + meal=instance, + owner=instance.owner, + file=files_data[i].get("file"), + ) + # Else if files have been removed, delete MealFiles + elif len(files_data) < len(files.all()): + for i in range(len(files_data), len(files.all())): + files.all()[i].delete() + + return instance + + def get_owner_username(self, obj): + """Returns the owning user's username + + Args: + obj (Meal): Current Meal + + Returns: + str: Username of owner + """ + return obj.owner.username \ No newline at end of file diff --git a/backend/secfit/meals/tests.py b/backend/secfit/meals/tests.py new file mode 100644 index 0000000..241c0b6 --- /dev/null +++ b/backend/secfit/meals/tests.py @@ -0,0 +1,6 @@ +""" +Tests for the meals application. +""" +from django.test import TestCase + +# Create your tests here. diff --git a/backend/secfit/meals/urls.py b/backend/secfit/meals/urls.py new file mode 100644 index 0000000..a880a65 --- /dev/null +++ b/backend/secfit/meals/urls.py @@ -0,0 +1,31 @@ +from django.urls import path, include +from meals import views +from rest_framework.urlpatterns import format_suffix_patterns +from rest_framework_simplejwt.views import ( + TokenObtainPairView, + TokenRefreshView, +) + +# This is messy and should be refactored +urlpatterns = format_suffix_patterns( + [ + path("", views.api_root), + path("api/meals/", views.MealList.as_view(), name="meal-list"), + path( + "api/meals/<int:pk>/", + views.MealDetail.as_view(), + name="meal-detail", + ), + path( + "api/meal-files/", + views.MealFileList.as_view(), + name="meal-file-list", + ), + path( + "api/meal-files/<int:pk>/", + views.MealFileDetail.as_view(), + name="mealfile-detail", + ), + path("", include("users.urls")), + ] +) diff --git a/backend/secfit/meals/views.py b/backend/secfit/meals/views.py new file mode 100644 index 0000000..7eddefa --- /dev/null +++ b/backend/secfit/meals/views.py @@ -0,0 +1,155 @@ +"""Contains views for the meals application. These are mostly class-based views. +""" +from rest_framework import generics, mixins +from rest_framework import permissions + +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 meals.parsers import MultipartJsonParser +from meals.permissions import ( + IsOwner, + IsOwnerOfMeal, + IsReadOnly, +) +from meals.mixins import CreateListModelMixin +from meals.models import Meal, MealFile +from meals.serializers import MealSerializer +from meals.serializers import MealFileSerializer +from django.core.exceptions import PermissionDenied +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): + return Response( + { + "users": reverse("user-list", request=request, format=format), + "meals": reverse("meal-list", request=request, format=format), + "meal-files": reverse( + "meal-file-list", request=request, format=format + ), + } + ) + +class MealList( + mixins.ListModelMixin, mixins.CreateModelMixin, generics.GenericAPIView +): + """Class defining the web response for the creation of a Meal, or displaying a list + of Meals + + HTTP methods: GET, POST + """ + + serializer_class = MealSerializer + permission_classes = [ + permissions.IsAuthenticated + ] # User must be authenticated to create/view meals + parser_classes = [ + MultipartJsonParser, + JSONParser, + ] # For parsing JSON and Multi-part requests + filter_backends = [filters.OrderingFilter] + ordering_fields = ["name", "date", "owner__username"] + + 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 = Meal.objects.none() + if self.request.user: + qs = Meal.objects.filter(Q(owner=self.request.user)).distinct() + return qs + +class MealDetail( + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + generics.GenericAPIView, +): + """Class defining the web response for the details of an individual Meal. + + HTTP methods: GET, PUT, DELETE + """ + + queryset = Meal.objects.all() + serializer_class = MealSerializer + permission_classes = [ + permissions.IsAuthenticated + & IsOwner + ] + parser_classes = [MultipartJsonParser, JSONParser] + + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + def put(self, request, *args, **kwargs): + return self.update(request, *args, **kwargs) + + def delete(self, request, *args, **kwargs): + return self.destroy(request, *args, **kwargs) + +class MealFileList( + mixins.ListModelMixin, + mixins.CreateModelMixin, + CreateListModelMixin, + generics.GenericAPIView, +): + + queryset = MealFile.objects.all() + serializer_class = MealFileSerializer + permission_classes = [permissions.IsAuthenticated & IsOwnerOfMeal] + 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 = MealFile.objects.none() + if self.request.user: + qs = MealFile.objects.filter( + Q(owner=self.request.user)).distinct() + return qs + + +class MealFileDetail( + mixins.RetrieveModelMixin, + mixins.UpdateModelMixin, + mixins.DestroyModelMixin, + generics.GenericAPIView, +): + + queryset = MealFile.objects.all() + serializer_class = MealFileSerializer + permission_classes = [ + permissions.IsAuthenticated + & IsOwner + ] + + def get(self, request, *args, **kwargs): + return self.retrieve(request, *args, **kwargs) + + def delete(self, request, *args, **kwargs): + return self.destroy(request, *args, **kwargs) diff --git a/backend/secfit/secfit/settings.py b/backend/secfit/secfit/settings.py index dcf82bd..96dbdcd 100644 --- a/backend/secfit/secfit/settings.py +++ b/backend/secfit/secfit/settings.py @@ -59,6 +59,7 @@ INSTALLED_APPS = [ "django.contrib.staticfiles", "rest_framework", "workouts.apps.WorkoutsConfig", + "meals.apps.MealsConfig", "users.apps.UsersConfig", "comments.apps.CommentsConfig", "corsheaders", diff --git a/backend/secfit/secfit/urls.py b/backend/secfit/secfit/urls.py index 3146886..93fc190 100644 --- a/backend/secfit/secfit/urls.py +++ b/backend/secfit/secfit/urls.py @@ -21,6 +21,7 @@ from django.conf.urls.static import static urlpatterns = [ path("admin/", admin.site.urls), path("", include("workouts.urls")), + path("", include("meals.urls")), ] urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/backend/secfit/workouts/migrations/0004_auto_20211020_0950.py b/backend/secfit/workouts/migrations/0004_auto_20211020_0950.py new file mode 100644 index 0000000..2d83c60 --- /dev/null +++ b/backend/secfit/workouts/migrations/0004_auto_20211020_0950.py @@ -0,0 +1,28 @@ +# Generated by Django 3.1 on 2021-10-20 07:50 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('workouts', '0003_rememberme'), + ] + + operations = [ + migrations.AddField( + model_name='exercise', + name='calories', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='exercise', + name='duration', + field=models.IntegerField(default=0), + ), + migrations.AddField( + model_name='exercise', + name='muscleGroup', + field=models.TextField(default='Legs'), + ), + ] diff --git a/frontend/www/meal.html b/frontend/www/meal.html new file mode 100644 index 0000000..2337e42 --- /dev/null +++ b/frontend/www/meal.html @@ -0,0 +1,73 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Meal</title> + + <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1" crossorigin="anonymous"> + + <script src="https://kit.fontawesome.com/0ce6c392ca.js" crossorigin="anonymous"></script> + <link rel="stylesheet" href="styles/style.css"> + <script src="scripts/navbar.js" type="text/javascript" defer></script> +</head> +<body> + <navbar-el></navbar-el> + + <div class="container"> + <div class="row"> + <div class="col-lg"> + <h3 class="mt-3">View or edit your registered meal</h3> + </div> + </div> + <form class="row g-3 mb-4" id="form-meal"> + <div class="col-lg-6 "> + <label for="inputName" class="form-label">Name</label> + <input type="text" class="form-control" id="inputName" name="name" readonly> + </div> + <div class="col-lg-6"></div> + <div class="col-lg-6"> + <label for="inputDateTime" class="form-label">Date/Time</label> + <input type="datetime-local" class="form-control" id="inputDateTime" name="date" readonly> + </div> + <div class="col-lg-6"></div> + <div class="col-lg-6"> + <label for="inputOwner" class="form-label">Owner</label> + <input type="text" class="form-control" id="inputOwner" name="owner_username" readonly> + </div> + <div class="col-lg-6"></div> + <div class="col-lg-6"> + <label for="inputNotes" class="form-label">Notes</label> + <textarea class="form-control" id="inputNotes" name="notes" readonly></textarea> + </div> + <div class="col-lg-6"></div> + <div class="col-lg-6"> + <label for="inputCalories" class="form-label">Calories</label> + <input type="number" class="form-control" id="inputCalories" name="calories" readonly></input> + </div> + <div class="col-lg-6"></div> + <div class="col-lg-6"> + <div class="input-group"> + <input type="file" class="form-control" id="customFile" name="files" multiple disabled> + </div> + <div id="uploaded-files" class="ms-1 mt-2"> + </div> + </div> + <div class="col-lg-6"> + </div> + <div class="col-lg-6"> + <input type="button" class="btn btn-primary hide" id="btn-ok-meal" value=" OK "> + <input type="button" class="btn btn-primary hide" id="btn-edit-meal" value=" Edit "> + <input type="button" class="btn btn-secondary hide" id="btn-cancel-meal" value="Cancel"> + <input type="button" class="btn btn-danger float-end hide" id="btn-delete-meal" value="Delete"> + </div> + <div class="col-lg-6"></div> + </form> + </div> + + <script src="scripts/defaults.js"></script> + <script src="scripts/scripts.js"></script> + <script src="scripts/meal.js"></script> + <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/js/bootstrap.bundle.min.js" integrity="sha384-ygbV9kiqUc6oa4msXn9868pTtWMgiQaeYH7/t7LECLbyPA2x65Kgf80OJFdroafW" crossorigin="anonymous"></script> + </body> +</html> \ No newline at end of file diff --git a/frontend/www/meals.html b/frontend/www/meals.html new file mode 100644 index 0000000..424969b --- /dev/null +++ b/frontend/www/meals.html @@ -0,0 +1,55 @@ +<!DOCTYPE html> +<html lang="en"> +<head> + <meta charset="UTF-8"> + <meta name="viewport" content="width=device-width, initial-scale=1.0"> + <title>Meals</title> + <link rel="stylesheet" href="styles/style.css"> + <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1" crossorigin="anonymous"> + + <script src="https://kit.fontawesome.com/0ce6c392ca.js" crossorigin="anonymous"></script> + <script src="scripts/navbar.js" type="text/javascript" defer></script> +</head> +<body> + <navbar-el></navbar-el> + + <div class="container"> + <div class="row"> + <div class="col-lg text-center"> + <h3 class="mt-5">View meals</h3> + <p>Here you can view your logged meals. Click on a meal to view its details.</p> + <input type="button" class="btn btn-success" id="btn-create-meal" value="Log new meal"> + </div> + </div> + <div class="row"> + <div class="col-lg text-center"> + <div class="mt-1">Sort by: <a href="?ordering=date">Date</a> <a href="?ordering=name">Name</a> + <br>Currently sorting by: <span id="current-sort"></span> + </div> + <div class="list-group mt-1" id="div-content"></div> + </div> + </div> + + </div> + + <template id="template-meal"> + <a class="list-group-item list-group-item-action flex-column align-items-start my-1 meal"> + <div class="d-flex w-100 justify-content-between align-items-center"> + <h5 class="mb-1"></h5> + </div> + <div class="d-flex"> + <table class="mb-1 text-start"> + <tr><td>Date:</td><td></td></tr> + <tr><td>Time:</td><td></td></tr> + <tr><td>Owner:</td><td></td></tr> + </table> + </div> + </a> + </template> + + <script src="scripts/defaults.js"></script> + <script src="scripts/scripts.js"></script> + <script src="scripts/meals.js"></script> + <script src="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/js/bootstrap.bundle.min.js" integrity="sha384-ygbV9kiqUc6oa4msXn9868pTtWMgiQaeYH7/t7LECLbyPA2x65Kgf80OJFdroafW" crossorigin="anonymous"></script> +</body> +</html> \ No newline at end of file diff --git a/frontend/www/scripts/exercise.js b/frontend/www/scripts/exercise.js index b2f7438..f845fe1 100644 --- a/frontend/www/scripts/exercise.js +++ b/frontend/www/scripts/exercise.js @@ -6,14 +6,29 @@ let oldFormData; class MuscleGroup { constructor(type) { - this.type = type; + this.isValidType = false; + this.validTypes = ["Legs", "Chest", "Back", "Arms", "Abdomen", "Shoulders"] + + this.type = this.validTypes.includes(type) ? type : undefined; }; setMuscleGroupType = (newType) => { - this.type = newType + this.isValidType = false; + + if(this.validTypes.includes(newType)){ + this.isValidType = true; + this.type = newType; + } + else{ + alert("Invalid muscle group!"); + } + }; - getMuscleGroupType = () => this.type; + getMuscleGroupType = () => { + console.log(this.type, "SWIOEFIWEUFH") + return this.type; + } } function handleCancelButtonDuringEdit() { diff --git a/frontend/www/scripts/meal.js b/frontend/www/scripts/meal.js new file mode 100644 index 0000000..dd48cc9 --- /dev/null +++ b/frontend/www/scripts/meal.js @@ -0,0 +1,158 @@ +let cancelMealButton; +let okMealButton; +let deleteMealButton; +let editMealButton; + +async function retrieveMeal(id) { + let mealData = null; + let response = await sendRequest("GET", `${HOST}/api/meals/${id}/`); + if (!response.ok) { + let data = await response.json(); + let alert = createAlert("Could not retrieve your meal data!", data); + document.body.prepend(alert); + } else { + mealData = await response.json(); + let form = document.querySelector("#form-meal"); + let formData = new FormData(form); + + for (let key of formData.keys()) { + let selector = `input[name="${key}"], textarea[name="${key}"]`; + let input = form.querySelector(selector); + let newVal = mealData[key]; + if (key == "date") { + // Creating a valid datetime-local string with the correct local time + let date = new Date(newVal); + date = new Date(date.getTime() - (date.getTimezoneOffset() * 60 * 1000)).toISOString(); // get ISO format for local time + newVal = date.substring(0, newVal.length - 1); // remove Z (since this is a local time, not UTC) + } + if (key != "files") { + input.value = newVal; + } + } + + let input = form.querySelector("select:disabled"); + // files + let filesDiv = document.querySelector("#uploaded-files"); + for (let file of mealData.files) { + let a = document.createElement("a"); + a.href = file.file; + let pathArray = file.file.split("/"); + a.text = pathArray[pathArray.length - 1]; + a.className = "me-2"; + filesDiv.appendChild(a); + } + } + return mealData; +} + +function handleCancelDuringMealEdit() { + location.reload(); +} + +function handleEditMealButtonClick() { + + setReadOnly(false, "#form-meal"); + document.querySelector("#inputOwner").readOnly = true; // owner field should still be readonly + + editMealButton.className += " hide"; // The edit button should be hidden when in edit mode + okMealButton.className = okMealButton.className.replace(" hide", ""); // The ok button should not be hidden when in edit mode + cancelMealButton.className = cancelMealButton.className.replace(" hide", ""); // See above + deleteMealButton.className = deleteMealButton.className.replace(" hide", ""); // See above + cancelMealButton.addEventListener("click", handleCancelDuringMealEdit); + +} + +async function deleteMeal(id) { + let response = await sendRequest("DELETE", `${HOST}/api/meals/${id}/`); + if (!response.ok) { + let data = await response.json(); + let alert = createAlert(`Could not delete this meal. ID: ${id}!`, data); + document.body.prepend(alert); + } else { + window.location.replace("meals.html"); + } +} + +async function updateMeal(id) { + let submitForm = generateMealForm(); + + let response = await sendRequest("PUT", `${HOST}/api/meals/${id}/`, submitForm, ""); + if (!response.ok) { + let data = await response.json(); + let alert = createAlert("Could not update your meal! :-( ", data); + document.body.prepend(alert); + } else { + location.reload(); + } +} + +function generateMealForm() { + let form = document.querySelector("#form-meal"); + + let formData = new FormData(form); + let submitForm = new FormData(); + + submitForm.append("name", formData.get('name')); + let date = new Date(formData.get('date')).toISOString(); + submitForm.append("date", date); + submitForm.append("notes", formData.get("notes")); + submitForm.append("calories", formData.get("calories")); + + // Adds the files + for (let file of formData.getAll("files")) { + submitForm.append("files", file); + } + return submitForm; +} + +async function createMeal() { + let submitForm = generateMealForm(); + + let response = await sendRequest("POST", `${HOST}/api/meals/`, submitForm, ""); + + if (response.ok) { + window.location.replace("meals.html"); + } else { + let data = await response.json(); + let alert = createAlert("Could not create new meal", data); + document.body.prepend(alert); + } +} + +function handleCancelDuringMealCreate() { + window.location.replace("meals.html"); +} + +window.addEventListener("DOMContentLoaded", async () => { + cancelMealButton = document.querySelector("#btn-cancel-meal"); + okMealButton = document.querySelector("#btn-ok-meal"); + deleteMealButton = document.querySelector("#btn-delete-meal"); + editMealButton = document.querySelector("#btn-edit-meal"); + + const urlParams = new URLSearchParams(window.location.search); + let currentUser = await getCurrentUser(); + + if (urlParams.has('id')) { + const id = urlParams.get('id'); + let mealData = await retrieveMeal(id); + + if (mealData["owner"] == currentUser.url) { + editMealButton.classList.remove("hide"); + editMealButton.addEventListener("click", handleEditMealButtonClick); + deleteMealButton.addEventListener("click", (async (id) => await deleteMeal(id)).bind(undefined, id)); + okMealButton.addEventListener("click", (async (id) => await updateMeal(id)).bind(undefined, id)); + } + } else { + let ownerInput = document.querySelector("#inputOwner"); + ownerInput.value = currentUser.username; + setReadOnly(false, "#form-meal"); + ownerInput.readOnly = !ownerInput.readOnly; + + okMealButton.className = okMealButton.className.replace(" hide", ""); + cancelMealButton.className = cancelMealButton.className.replace(" hide", ""); + + okMealButton.addEventListener("click", async () => await createMeal()); + cancelMealButton.addEventListener("click", handleCancelDuringMealCreate); + } + +}); \ No newline at end of file diff --git a/frontend/www/scripts/meals.js b/frontend/www/scripts/meals.js new file mode 100644 index 0000000..2ffed1d --- /dev/null +++ b/frontend/www/scripts/meals.js @@ -0,0 +1,91 @@ +async function fetchMeals(ordering) { + let response = await sendRequest("GET", `${HOST}/api/meals/?ordering=${ordering}`); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } else { + let data = await response.json(); + + let meals = data.results; + let container = document.getElementById('div-content'); + meals.forEach(meal => { + let templateMeal = document.querySelector("#template-meal"); + let cloneMeal = templateMeal.content.cloneNode(true); + + let aMeal = cloneMeal.querySelector("a"); + aMeal.href = `meal.html?id=${meal.id}`; + + let h5 = aMeal.querySelector("h5"); + h5.textContent = meal.name; + + let localDate = new Date(meal.date); + + let table = aMeal.querySelector("table"); + let rows = table.querySelectorAll("tr"); + rows[0].querySelectorAll("td")[1].textContent = localDate.toLocaleDateString(); // Date + rows[1].querySelectorAll("td")[1].textContent = localDate.toLocaleTimeString(); // Time + rows[2].querySelectorAll("td")[1].textContent = meal.owner_username; //Owner + + container.appendChild(aMeal); + }); + return meals; + } +} + +function createMeal() { + window.location.replace("meal.html"); +} + +window.addEventListener("DOMContentLoaded", async () => { + let createButton = document.querySelector("#btn-create-meal"); + createButton.addEventListener("click", createMeal); + let ordering = "-date"; + + const urlParams = new URLSearchParams(window.location.search); + if (urlParams.has('ordering')) { + let aSort = null; + ordering = urlParams.get('ordering'); + if (ordering == "name" || ordering == "owner" || ordering == "date") { + let aSort = document.querySelector(`a[href="?ordering=${ordering}"`); + aSort.href = `?ordering=-${ordering}`; + } + } + + let currentSort = document.querySelector("#current-sort"); + currentSort.innerHTML = (ordering.startsWith("-") ? "Descending" : "Ascending") + " " + ordering.replace("-", ""); + + let currentUser = await getCurrentUser(); + // grab username + if (ordering.includes("owner")) { + ordering += "__username"; + } + let meals = await fetchMeals(ordering); + + let tabEls = document.querySelectorAll('a[data-bs-toggle="list"]'); + for (let i = 0; i < tabEls.length; i++) { + let tabEl = tabEls[i]; + tabEl.addEventListener('show.bs.tab', function (event) { + let mealAnchors = document.querySelectorAll('.meal'); + for (let j = 0; j < meals.length; j++) { + // I'm assuming that the order of meal objects matches + // the other of the meal anchor elements. They should, given + // that I just created them. + let meal = meals[j]; + let mealAnchor = mealAnchors[j]; + + switch (event.currentTarget.id) { + case "list-my-meals-list": + if (meal.owner == currentUser.url) { + mealAnchor.classList.remove('hide'); + } else { + mealAnchor.classList.add('hide'); + } + break; + default : + mealAnchor.classList.remove('hide'); + break; + } + } + }); + } +}); \ No newline at end of file diff --git a/frontend/www/scripts/navbar.js b/frontend/www/scripts/navbar.js index d906f61..8dec8c1 100644 --- a/frontend/www/scripts/navbar.js +++ b/frontend/www/scripts/navbar.js @@ -18,6 +18,7 @@ class NavBar extends HTMLElement { <a class="nav-link hide" id="nav-exercises" href="exercises.html">Exercises</a> <a class="nav-link hide" id="nav-mycoach" href="mycoach.html">Coach</a> <a class="nav-link hide" id="nav-myathletes" href="myathletes.html">Athletes</a> + <a class="nav-link hide" id="nav-meals" href="meals.html">Meal registration</a> <hr> </div> <div class="my-2 my-lg-0 me-5"> diff --git a/frontend/www/scripts/scripts.js b/frontend/www/scripts/scripts.js index 8c55000..9bfc1ef 100644 --- a/frontend/www/scripts/scripts.js +++ b/frontend/www/scripts/scripts.js @@ -22,6 +22,8 @@ function updateNavBar() { makeNavLinkActive("nav-mycoach") } else if (window.location.pathname == "/myathletes.html") { makeNavLinkActive("nav-myathletes"); + } else if (window.location.pathname == "/meals.html") { + makeNavLinkActive("nav-myathletes"); } if (isUserAuthenticated()) { @@ -32,6 +34,7 @@ function updateNavBar() { document.querySelector('a[href="mycoach.html"').classList.remove("hide"); document.querySelector('a[href="exercises.html"').classList.remove("hide"); document.querySelector('a[href="myathletes.html"').classList.remove("hide"); + document.querySelector('a[href="meals.html"').classList.remove("hide"); } else { document.getElementById("btn-login-nav").classList.remove("hide"); document.getElementById("btn-register").classList.remove("hide"); -- GitLab