├── backend
├── auth
│ ├── __init__.py
│ ├── apps.py
│ ├── urls.py
│ ├── serializers.py
│ └── views.py
├── backend
│ ├── __init__.py
│ ├── settings
│ │ ├── __init__.py
│ │ ├── dev.py
│ │ ├── prod.py
│ │ └── base.py
│ ├── asgi.py
│ ├── wsgi.py
│ └── urls.py
├── common
│ ├── __init__.py
│ └── models.py
├── core
│ ├── __init__.py
│ ├── migrations
│ │ ├── __init__.py
│ │ └── 0001_initial.py
│ ├── apps.py
│ ├── permissions.py
│ ├── urls.py
│ ├── admin.py
│ ├── views.py
│ ├── serializers.py
│ ├── models.py
│ └── fixtures
│ │ └── exercise.json
├── user
│ ├── __init__.py
│ ├── migrations
│ │ ├── __init__.py
│ │ └── 0001_initial.py
│ ├── apps.py
│ ├── serializers.py
│ ├── tests.py
│ ├── views.py
│ ├── admin.py
│ └── models.py
├── entrypoint.sh
├── requirements.txt
├── Dockerfile
├── .dockerignore
├── manage.py
├── README.md
└── .gitignore
├── frontend
├── src
│ ├── i18n
│ │ ├── eng.json
│ │ ├── rus.json
│ │ └── ukr.json
│ ├── api
│ │ ├── instances
│ │ │ ├── queryClient.js
│ │ │ └── axiosInstance.js
│ │ ├── profileApi.js
│ │ ├── workoutApi.js
│ │ ├── authApi.js
│ │ └── exerciseApi.js
│ ├── utils
│ │ ├── helpers.js
│ │ └── routes.js
│ ├── components
│ │ ├── ProtectedRoute.js
│ │ ├── Exercises
│ │ │ ├── FilterExercises
│ │ │ │ ├── ViewExercises.js
│ │ │ │ ├── ExerciseDropdown.js
│ │ │ │ ├── SelectExercises.js
│ │ │ │ └── index.js
│ │ │ ├── NewExerciseModal
│ │ │ │ ├── Select.js
│ │ │ │ └── index.js
│ │ │ └── index.js
│ │ ├── Footer.js
│ │ ├── History
│ │ │ ├── WorkoutExercise.js
│ │ │ ├── WorkoutExerciseDetails.js
│ │ │ └── index.js
│ │ ├── Workout
│ │ │ ├── index.js
│ │ │ └── NewWorkout
│ │ │ │ ├── ListWorkoutExercises.js
│ │ │ │ ├── AddExercisesModal.js
│ │ │ │ ├── index.js
│ │ │ │ └── AddWorkoutExerciseDetails.js
│ │ ├── NavigationBar.js
│ │ ├── Profile
│ │ │ └── index.js
│ │ ├── Login
│ │ │ └── index.js
│ │ └── Register
│ │ │ └── index.js
│ ├── redux
│ │ ├── slices
│ │ │ ├── authSlice.js
│ │ │ └── workoutSlice.js
│ │ └── store.js
│ ├── index.js
│ ├── styles
│ │ └── index.css
│ └── App.js
├── public
│ ├── robots.txt
│ ├── favicon.ico
│ ├── logo192.png
│ ├── logo512.png
│ ├── manifest.json
│ └── index.html
├── .env
├── Dockerfile
├── README.md
├── .gitignore
└── package.json
├── heroku.yml
├── docker-compose.yml
├── Dockerfile.prod
├── README.md
└── LICENSE
/backend/auth/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/backend/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/common/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/core/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/user/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/i18n/eng.json:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/i18n/rus.json:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/frontend/src/i18n/ukr.json:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/backend/settings/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/core/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/user/migrations/__init__.py:
--------------------------------------------------------------------------------
1 |
--------------------------------------------------------------------------------
/backend/backend/settings/dev.py:
--------------------------------------------------------------------------------
1 | from backend.settings.base import *
2 |
3 | DEBUG = True
4 |
--------------------------------------------------------------------------------
/frontend/public/robots.txt:
--------------------------------------------------------------------------------
1 | # https://www.robotstxt.org/robotstxt.html
2 | User-agent: *
3 | Disallow:
4 |
--------------------------------------------------------------------------------
/frontend/public/favicon.ico:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vlad-moroshan/gym-log/HEAD/frontend/public/favicon.ico
--------------------------------------------------------------------------------
/frontend/public/logo192.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vlad-moroshan/gym-log/HEAD/frontend/public/logo192.png
--------------------------------------------------------------------------------
/frontend/public/logo512.png:
--------------------------------------------------------------------------------
https://raw.githubusercontent.com/vlad-moroshan/gym-log/HEAD/frontend/public/logo512.png
--------------------------------------------------------------------------------
/frontend/src/api/instances/queryClient.js:
--------------------------------------------------------------------------------
1 | import {QueryClient} from "react-query";
2 |
3 | export const queryClient = new QueryClient();
--------------------------------------------------------------------------------
/heroku.yml:
--------------------------------------------------------------------------------
1 | build:
2 | docker:
3 | web: Dockerfile.prod
4 | run:
5 | web: python3 backend/manage.py runserver 0.0.0.0:$PORT
6 |
--------------------------------------------------------------------------------
/frontend/src/utils/helpers.js:
--------------------------------------------------------------------------------
1 | export const timestampToString = (timestamp) => {
2 | const date = new Date(timestamp);
3 | return date.toDateString().toLocaleString();
4 | }
--------------------------------------------------------------------------------
/backend/auth/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class AuthConfig(AppConfig):
5 | default_auto_field = 'django.db.models.BigAutoField'
6 | name = 'auth'
7 |
--------------------------------------------------------------------------------
/backend/core/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class CoreConfig(AppConfig):
5 | default_auto_field = 'django.db.models.BigAutoField'
6 | name = 'core'
7 |
--------------------------------------------------------------------------------
/backend/user/apps.py:
--------------------------------------------------------------------------------
1 | from django.apps import AppConfig
2 |
3 |
4 | class UserConfig(AppConfig):
5 | default_auto_field = 'django.db.models.BigAutoField'
6 | name = 'user'
7 |
--------------------------------------------------------------------------------
/frontend/.env:
--------------------------------------------------------------------------------
1 | REACT_APP_NAME=Gym Log
2 | REACT_APP_AUTHOR_LINKEDIN_URL=https://www.linkedin.com/in/vladislavalerievich/
3 | REACT_APP_GITHUB_URL=https://github.com/vladislavalerievich/gym-log
--------------------------------------------------------------------------------
/backend/entrypoint.sh:
--------------------------------------------------------------------------------
1 | #!/bin/bash
2 | python manage.py makemigrations --no-input
3 | python manage.py migrate --no-input
4 | python manage.py loaddata core/fixtures/exercise.json
5 |
6 | python manage.py runserver 0.0.0.0:$PORT
7 |
--------------------------------------------------------------------------------
/frontend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM node:alpine
2 |
3 | WORKDIR /app/
4 |
5 | # Install dependencies
6 | COPY package*.json /app/
7 | RUN npm install
8 |
9 | # Add rest of the frontend code
10 | COPY . /app/
11 |
12 | EXPOSE 3000
13 |
14 | CMD npm start
--------------------------------------------------------------------------------
/backend/requirements.txt:
--------------------------------------------------------------------------------
1 | Django==4.0.4
2 | django-cors-headers==3.12.0
3 | django-model-utils==4.2.0
4 | dj-database-url== 0.5.0
5 | djangorestframework==3.13.1
6 | djangorestframework-simplejwt==5.1.0
7 | drf-yasg==1.20.0
8 | whitenoise==6.1.0
9 | psycopg2-binary==2.9.3
--------------------------------------------------------------------------------
/frontend/README.md:
--------------------------------------------------------------------------------
1 | # Gym Log (frontend)
2 |
3 | ## Local development
4 |
5 | Setup local environment for the development process in the current directory `gym-log/frontend`.
6 |
7 | ### Run in a terminal
8 |
9 | ```shell
10 | npm install
11 | npm start
12 | ```
--------------------------------------------------------------------------------
/backend/core/permissions.py:
--------------------------------------------------------------------------------
1 | from rest_framework import permissions
2 |
3 |
4 | class IsOwner(permissions.BasePermission):
5 | """
6 | Custom permission that allow only owners of an object to read, update or delete it.
7 | """
8 |
9 | def has_object_permission(self, request, view, obj):
10 | return obj.user == request.user
11 |
--------------------------------------------------------------------------------
/frontend/src/api/profileApi.js:
--------------------------------------------------------------------------------
1 | import axiosInstance from "./instances/axiosInstance";
2 | import {apiRoutes} from "../utils/routes";
3 |
4 | export const getProfile = () => axiosInstance.get(apiRoutes.profile).then(response => response.data[0]);
5 | export const updateProfile = (data) => axiosInstance.post(apiRoutes.profile, data).then(response => response.data);
--------------------------------------------------------------------------------
/backend/common/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 | from model_utils.fields import AutoCreatedField, AutoLastModifiedField
3 |
4 |
5 | class IndexedTimeStampedModel(models.Model):
6 | created = AutoCreatedField("created", db_index=True)
7 | modified = AutoLastModifiedField("modified", db_index=True)
8 |
9 | class Meta:
10 | abstract = True
11 |
--------------------------------------------------------------------------------
/backend/core/urls.py:
--------------------------------------------------------------------------------
1 | from rest_framework.routers import SimpleRouter
2 |
3 | from core.views import ProfileViewSet, ExerciseViewSet, WorkoutViewSet
4 |
5 | core_router = SimpleRouter()
6 |
7 | core_router.register(r'profile', ProfileViewSet, basename='profile')
8 | core_router.register(r'exercise', ExerciseViewSet, basename='exercise')
9 | core_router.register(r'workout', WorkoutViewSet, basename='workout')
10 |
--------------------------------------------------------------------------------
/backend/user/serializers.py:
--------------------------------------------------------------------------------
1 | from rest_framework import serializers
2 |
3 | from user.models import User
4 |
5 |
6 | class UserSerializer(serializers.ModelSerializer):
7 | created = serializers.DateTimeField(read_only=True)
8 | modified = serializers.DateTimeField(read_only=True)
9 |
10 | class Meta:
11 | model = User
12 | fields = ['id', 'username', 'email', 'created', 'modified']
13 |
--------------------------------------------------------------------------------
/backend/backend/asgi.py:
--------------------------------------------------------------------------------
1 | """
2 | ASGI config for backend project.
3 |
4 | It exposes the ASGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.2/howto/deployment/asgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.asgi import get_asgi_application
13 |
14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
15 |
16 | application = get_asgi_application()
17 |
--------------------------------------------------------------------------------
/backend/backend/wsgi.py:
--------------------------------------------------------------------------------
1 | """
2 | WSGI config for backend project.
3 |
4 | It exposes the WSGI callable as a module-level variable named ``application``.
5 |
6 | For more information on this file, see
7 | https://docs.djangoproject.com/en/3.2/howto/deployment/wsgi/
8 | """
9 |
10 | import os
11 |
12 | from django.core.wsgi import get_wsgi_application
13 |
14 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings')
15 |
16 | application = get_wsgi_application()
17 |
--------------------------------------------------------------------------------
/backend/user/tests.py:
--------------------------------------------------------------------------------
1 | from django.test import TestCase
2 |
3 | from user.models import User
4 |
5 |
6 | class AuthTestCase(TestCase):
7 | def setUp(self):
8 | self.u = User.objects.create_user('test@mail.com', 'test', 'pass')
9 | self.u.is_staff = True
10 | self.u.is_superuser = True
11 | self.u.is_active = True
12 | self.u.save()
13 |
14 | def testLogin(self):
15 | self.client.login(username='test', password='pass')
16 |
--------------------------------------------------------------------------------
/frontend/src/api/workoutApi.js:
--------------------------------------------------------------------------------
1 | import axiosInstance from "./instances/axiosInstance";
2 | import {apiRoutes} from "../utils/routes";
3 |
4 | export const getWorkoutHistory = () => axiosInstance.get(apiRoutes.workout).then(response => response.data);
5 | export const createWorkout = (data) => axiosInstance.post(apiRoutes.workout, data).then(response => response.data);
6 | export const deleteWorkout = (id) => axiosInstance.delete(apiRoutes.workout + id + '/').then(response => response.data);
7 |
--------------------------------------------------------------------------------
/frontend/src/api/authApi.js:
--------------------------------------------------------------------------------
1 | import axiosInstance from "./instances/axiosInstance";
2 | import {apiRoutes} from "../utils/routes";
3 |
4 | export const login = (data) => axiosInstance.post(apiRoutes.login, data).then(response => response.data);
5 | export const register = (data) => axiosInstance.post(apiRoutes.register, data).then(response => response.data);
6 | export const blacklist = () => axiosInstance.post(apiRoutes.blacklist,
7 | {"refresh": localStorage.getItem('refreshToken')}).then(response => response.data);
8 |
--------------------------------------------------------------------------------
/frontend/.gitignore:
--------------------------------------------------------------------------------
1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
2 |
3 | # dependencies
4 | /node_modules
5 | /.pnp
6 | .pnp.js
7 | npm-debug.log*
8 | yarn-debug.log*
9 | yarn-error.log*
10 |
11 | # testing
12 | /coverage
13 |
14 | # production
15 | /build
16 |
17 | # misc
18 | .DS_Store
19 | .env.local
20 | .env.development.local
21 | .env.test.local
22 | .env.production.local
23 |
24 | npm-debug.log*
25 | yarn-debug.log*
26 | yarn-error.log*
27 |
28 | # VS Code
29 | workspace*.json
--------------------------------------------------------------------------------
/backend/auth/urls.py:
--------------------------------------------------------------------------------
1 | from rest_framework.routers import SimpleRouter
2 |
3 | from auth.views import LoginViewSet, RegistrationViewSet, RefreshViewSet, BlacklistTokenViewSet
4 |
5 | auth_router = SimpleRouter()
6 |
7 | auth_router.register(r'auth/login', LoginViewSet, basename='auth-login')
8 | auth_router.register(r'auth/register', RegistrationViewSet, basename='auth-register')
9 | auth_router.register(r'auth/refresh', RefreshViewSet, basename='auth-refresh')
10 | auth_router.register(r'auth/blacklist', BlacklistTokenViewSet, basename='auth-blacklist')
11 |
--------------------------------------------------------------------------------
/backend/Dockerfile:
--------------------------------------------------------------------------------
1 | FROM python:3.10-slim
2 |
3 | # Adding backend directory to make absolute filepaths consistent across services
4 | WORKDIR /app/backend
5 |
6 | # Set environment variables
7 | ENV PYTHONDONTWRITEBYTECODE 1
8 | ENV PYTHONUNBUFFERED 1
9 |
10 | # Install Python dependencies
11 | COPY requirements.txt /app/backend
12 | RUN pip3 install --upgrade pip -r requirements.txt
13 |
14 | COPY . /app/backend
15 |
16 | ENV PORT 8000
17 | EXPOSE 8000
18 |
19 | RUN ["chmod", "+x", "/app/backend/entrypoint.sh"]
20 | ENTRYPOINT ["/app/backend/entrypoint.sh"]
21 |
--------------------------------------------------------------------------------
/frontend/src/api/exerciseApi.js:
--------------------------------------------------------------------------------
1 | import axiosInstance from "./instances/axiosInstance";
2 | import {apiRoutes} from "../utils/routes";
3 |
4 | export const getExercises = () => axiosInstance.get(apiRoutes.exercise).then(response => response.data);
5 | export const getBodyParts = () => axiosInstance.get(apiRoutes.bodyParts).then(response => response.data);
6 | export const getEquipment = () => axiosInstance.get(apiRoutes.equipment).then(response => response.data);
7 | export const createExercise = (data) => axiosInstance.post(apiRoutes.exercise, data).then(response => response.data);
--------------------------------------------------------------------------------
/backend/.dockerignore:
--------------------------------------------------------------------------------
1 | # Django project
2 | /media/
3 | /static/
4 | *.sqlite3
5 |
6 | # Python and others
7 | __pycache__
8 | *.pyc
9 | .DS_Store
10 | *.swp
11 | /venv/
12 | /tmp/
13 | /.vagrant/
14 | /Vagrantfile.local
15 | node_modules/
16 | /npm-debug.log
17 | /.idea/
18 | .vscode
19 | coverage
20 | .python-version
21 |
22 | # Distribution / packaging
23 | .Python
24 | env/
25 | build/
26 | develop-eggs/
27 | dist/
28 | downloads/
29 | eggs/
30 | .eggs/
31 | lib/
32 | lib64/
33 | parts/
34 | sdist/
35 | var/
36 | wheels/
37 | *.egg-info/
38 | .installed.cfg
39 | *.egg
40 |
--------------------------------------------------------------------------------
/frontend/src/components/ProtectedRoute.js:
--------------------------------------------------------------------------------
1 | import {Navigate, useLocation} from "react-router-dom";
2 | import {useSelector} from "react-redux";
3 | import {pageRoutes} from "../utils/routes";
4 |
5 |
6 | const ProtectedRoute = ({children}) => {
7 | const location = useLocation();
8 | const authenticated = useSelector((state) => state.auth.authenticated)
9 |
10 | if (!authenticated) {
11 | return ;
12 | }
13 |
14 | return children;
15 | };
16 |
17 | export default ProtectedRoute;
18 |
--------------------------------------------------------------------------------
/docker-compose.yml:
--------------------------------------------------------------------------------
1 | version: "3.8"
2 | services:
3 | backend:
4 | build: ./backend
5 | volumes:
6 | - ./backend:/app/backend
7 | ports:
8 | - "8000:8000"
9 | stdin_open: true
10 | tty: true
11 | environment:
12 | - DJANGO_SETTINGS_MODULE=backend.settings.dev
13 |
14 | frontend:
15 | build: ./frontend
16 | volumes:
17 | - ./frontend:/app
18 | - /app/node_modules
19 | ports:
20 | - "3000:3000"
21 | environment:
22 | - NODE_ENV=development
23 | depends_on:
24 | - backend
25 | command: npm start
26 |
27 |
--------------------------------------------------------------------------------
/frontend/src/utils/routes.js:
--------------------------------------------------------------------------------
1 | export const apiRoutes = {
2 | login: '/auth/login/',
3 | register: '/auth/register/',
4 | refresh: '/auth/refresh/',
5 | blacklist: '/auth/blacklist/',
6 | profile: '/profile/',
7 | workout: '/workout/',
8 | exercise: '/exercise/',
9 | bodyParts: '/exercise/body_parts/',
10 | equipment: '/exercise/equipment/',
11 | };
12 |
13 | export const pageRoutes = {
14 | login: '/login',
15 | register: '/register',
16 | profile: '/profile',
17 | workout: '/workout',
18 | history: '/history',
19 | exercises: '/exercises',
20 | };
--------------------------------------------------------------------------------
/frontend/public/manifest.json:
--------------------------------------------------------------------------------
1 | {
2 | "short_name": "Gym Log",
3 | "name": "Gym Log",
4 | "icons": [
5 | {
6 | "src": "favicon.ico",
7 | "sizes": "64x64 32x32 24x24 16x16",
8 | "type": "image/x-icon"
9 | },
10 | {
11 | "src": "logo192.png",
12 | "type": "image/png",
13 | "sizes": "192x192"
14 | },
15 | {
16 | "src": "logo512.png",
17 | "type": "image/png",
18 | "sizes": "512x512"
19 | }
20 | ],
21 | "start_url": ".",
22 | "display": "standalone",
23 | "theme_color": "#000000",
24 | "background_color": "#ffffff"
25 | }
26 |
--------------------------------------------------------------------------------
/backend/user/views.py:
--------------------------------------------------------------------------------
1 | from rest_framework import viewsets, filters
2 | from rest_framework.permissions import IsAuthenticated
3 |
4 | from .models import User
5 | from user.serializers import UserSerializer
6 |
7 |
8 | class UserViewSet(viewsets.ModelViewSet):
9 | permission_classes = (IsAuthenticated,)
10 | http_method_names = ['get']
11 | serializer_class = UserSerializer
12 | filter_backends = [filters.OrderingFilter]
13 | ordering_fields = ['modified']
14 | ordering = ['-modified']
15 |
16 | def get_queryset(self):
17 | if self.request.user.is_superuser:
18 | return User.objects.all()
19 | return User.objects.filter(id=self.request.user.id)
20 |
--------------------------------------------------------------------------------
/frontend/src/components/Exercises/FilterExercises/ViewExercises.js:
--------------------------------------------------------------------------------
1 | import ListGroup from "react-bootstrap/ListGroup";
2 |
3 |
4 | const ViewExercises = ({exercises}) => {
5 | return (
6 |
7 | {exercises?.map(exercise =>
8 |
9 | {exercise.name} ({exercise.equipment})
10 | {exercise.body_part}
11 | {exercise.description}
12 |
13 | )}
14 |
15 | );
16 | };
17 |
18 | export default ViewExercises;
19 |
--------------------------------------------------------------------------------
/frontend/src/components/Footer.js:
--------------------------------------------------------------------------------
1 | const Footer = () => {
2 | return (
3 |
16 | );
17 | };
18 |
19 | export default Footer;
20 |
--------------------------------------------------------------------------------
/backend/manage.py:
--------------------------------------------------------------------------------
1 | #!/usr/bin/env python
2 | """Django's command-line utility for administrative tasks."""
3 | import os
4 | import sys
5 |
6 |
7 | def main():
8 | """Run administrative tasks."""
9 | os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'backend.settings.dev')
10 | try:
11 | from django.core.management import execute_from_command_line
12 | except ImportError as exc:
13 | raise ImportError(
14 | "Couldn't import Django. Are you sure it's installed and "
15 | "available on your PYTHONPATH environment variable? Did you "
16 | "forget to activate a virtual environment?"
17 | ) from exc
18 | execute_from_command_line(sys.argv)
19 |
20 |
21 | if __name__ == '__main__':
22 | main()
23 |
--------------------------------------------------------------------------------
/frontend/src/components/History/WorkoutExercise.js:
--------------------------------------------------------------------------------
1 | import {ListGroup} from "react-bootstrap";
2 | import WorkoutExerciseDetails from "./WorkoutExerciseDetails";
3 |
4 | const WorkoutExercise = ({exercises}) => {
5 | return (
6 |
7 |
Exercises:
8 |
9 | {exercises?.map(exercise =>
10 |
11 | {exercise.name}
12 |
13 |
14 | )}
15 |
16 |
17 | );
18 | };
19 |
20 | export default WorkoutExercise;
21 |
--------------------------------------------------------------------------------
/frontend/src/redux/slices/authSlice.js:
--------------------------------------------------------------------------------
1 | import {createSlice} from '@reduxjs/toolkit';
2 |
3 |
4 | const initialState = {
5 | authenticated: false,
6 | weightUnit: null,
7 | }
8 |
9 |
10 | export const authSlice = createSlice({
11 | name: 'auth',
12 | initialState,
13 | reducers: {
14 | login: (state, action) => {
15 | const {access, refresh} = action.payload;
16 | localStorage.setItem("accessToken", access);
17 | localStorage.setItem("refreshToken", refresh);
18 | state.authenticated = true
19 | },
20 | updateWeightUnit: (state, action) => {
21 | state.weightUnit = action.payload;
22 | },
23 | },
24 | })
25 |
26 | export const {
27 | login,
28 | updateWeightUnit
29 | } = authSlice.actions;
30 |
31 | export default authSlice.reducer;
--------------------------------------------------------------------------------
/backend/README.md:
--------------------------------------------------------------------------------
1 | # Gym Log (backend)
2 |
3 | ## Local development
4 |
5 | Setup local environment for the development process in the current directory `gym-log/backend`.
6 |
7 | Create and Activate virtual environment:
8 |
9 | ### Mac OS / Linux
10 |
11 | ```shell
12 | python3.10 -m venv env
13 | source venv/bin/activate
14 | ```
15 |
16 | ### Windows
17 |
18 | ```shell
19 | virtualenv --python=/usr/bin/python3.10 venv
20 | venv\Scripts\activate
21 | ```
22 |
23 | ### Run in a terminal
24 |
25 | ```shell
26 | pip install -r requirements.txt
27 | python manage.py migrate
28 | python manage.py loaddata ./core/fixtures/exercise.json
29 | python manage.py createsuperuser
30 | python manage.py runserver
31 | ```
32 |
33 | ### Django models entity relationships diagram
34 |
35 | 
--------------------------------------------------------------------------------
/frontend/src/components/Workout/index.js:
--------------------------------------------------------------------------------
1 | import {useDispatch, useSelector} from "react-redux";
2 | import Button from "react-bootstrap/Button";
3 | import NewWorkout from "./NewWorkout";
4 | import {startWorkout} from "../../redux/slices/workoutSlice";
5 |
6 |
7 | const Workout = () => {
8 | const workoutStarted = useSelector((state) => state.workout.workoutStarted)
9 | const dispatch = useDispatch()
10 |
11 | if (workoutStarted) {
12 | return
13 | } else {
14 | return (
15 |
16 |
dispatch(startWorkout())}>
17 | Start an empty workout
18 |
19 |
20 | );
21 | }
22 |
23 | };
24 |
25 | export default Workout;
26 |
--------------------------------------------------------------------------------
/backend/user/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 | from django.contrib.auth.admin import UserAdmin
3 |
4 | from user.models import User
5 |
6 |
7 | class CustomUserAdmin(UserAdmin):
8 | list_display = ("id", "email", "username", 'is_active', 'is_staff', "created", "modified")
9 | list_filter = ("is_active", "is_staff", "groups")
10 | search_fields = ("email", "username")
11 | filter_horizontal = (
12 | "groups",
13 | "user_permissions",
14 | )
15 |
16 | fieldsets = (
17 | (None, {"fields": ("email", "username", "password")}),
18 | ("Permissions", {"fields": ("is_active", "is_staff", "is_superuser", "groups", "user_permissions")}),
19 | )
20 | add_fieldsets = ((None, {"classes": ("wide",), "fields": ("email", "password1", "password2")}),)
21 |
22 |
23 | admin.site.register(User, CustomUserAdmin)
24 |
--------------------------------------------------------------------------------
/frontend/src/components/Exercises/NewExerciseModal/Select.js:
--------------------------------------------------------------------------------
1 | import PropTypes from "prop-types";
2 | import Form from "react-bootstrap/Form";
3 |
4 | const Select = ({label, selectValue, onSelect, options}) => {
5 | return (
6 |
7 | {label}
8 |
13 | {options?.map((value, index) => {value} )}
14 |
15 |
16 | );
17 | };
18 |
19 | Select.propTypes = {
20 | label: PropTypes.string,
21 | selectValue: PropTypes.string,
22 | onSelect: PropTypes.func.isRequired,
23 | options: PropTypes.array
24 | };
25 |
26 | export default Select;
27 |
--------------------------------------------------------------------------------
/frontend/src/components/Exercises/FilterExercises/ExerciseDropdown.js:
--------------------------------------------------------------------------------
1 | import Dropdown from "react-bootstrap/Dropdown";
2 |
3 |
4 | const ExerciseDropdown = ({dropdownId, label, items, selected, onSelect}) => {
5 | return (
6 |
7 | {selected}
8 |
9 | {label}
10 |
11 | {items?.map((value, index) =>
12 | {value}
13 | )}
14 |
15 |
16 | );
17 | };
18 |
19 | export default ExerciseDropdown;
20 |
--------------------------------------------------------------------------------
/frontend/src/index.js:
--------------------------------------------------------------------------------
1 | import React from "react";
2 | import ReactDOM from "react-dom";
3 | import {QueryClientProvider} from 'react-query';
4 | import {Provider} from 'react-redux'
5 | import {PersistGate} from "redux-persist/integration/react";
6 | import store, {persistor} from "./redux/store";
7 | import {queryClient} from "./api/instances/queryClient";
8 | import "bootstrap/dist/css/bootstrap.min.css";
9 | import "react-toastify/dist/ReactToastify.css";
10 | import "./styles/index.css";
11 | import App from "./App";
12 |
13 |
14 | ReactDOM.render(
15 |
16 |
17 |
18 |
19 |
20 |
21 |
22 |
23 | ,
24 | document.getElementById("root")
25 | );
--------------------------------------------------------------------------------
/Dockerfile.prod:
--------------------------------------------------------------------------------
1 | # Build step #1: build the React frontend
2 | FROM node:alpine as frontend-builder
3 | WORKDIR /frontend
4 | COPY ./frontend/ /frontend/
5 | RUN npm install && npm run build
6 |
7 | # Build step #2: build the backend API with the frontend as static files
8 | FROM python:3.10-slim
9 |
10 | WORKDIR /app/
11 | COPY --from=frontend-builder /frontend/build/ /app/frontend/build/
12 |
13 | # Move all static files other than index.html to root (for whitenoise middleware)
14 | WORKDIR /app/frontend/build
15 | RUN mkdir root && mv *.ico *.json root
16 |
17 | WORKDIR /app
18 | COPY ./backend/ /app/
19 | RUN pip3 install --upgrade pip -r requirements.txt
20 |
21 | # set environment variables
22 | ENV PYTHONDONTWRITEBYTECODE 1
23 | ENV PYTHONUNBUFFERED 1
24 | ENV DJANGO_SETTINGS_MODULE=backend.settings.prod
25 |
26 | ARG PORT
27 | ENV PORT=$PORT
28 | EXPOSE $PORT
29 |
30 | RUN python manage.py collectstatic --noinput
31 |
32 | RUN ["chmod", "+x", "/app/entrypoint.sh"]
33 | ENTRYPOINT ["/app/entrypoint.sh"]
--------------------------------------------------------------------------------
/frontend/src/components/History/WorkoutExerciseDetails.js:
--------------------------------------------------------------------------------
1 | import {useSelector} from "react-redux";
2 | import {ListGroup} from "react-bootstrap";
3 |
4 |
5 | const WorkoutExerciseDetails = ({exerciseDetails}) => {
6 | const weightUnit = useSelector((state) => state.auth.weightUnit);
7 |
8 | return (
9 |
10 |
11 | {exerciseDetails?.map(detail =>
12 |
13 |
14 | {detail.sets} sets
15 |
16 | {detail.reps} reps
17 | {detail.weight} {weightUnit}
18 |
19 |
20 | )}
21 |
22 |
23 | );
24 | };
25 |
26 | export default WorkoutExerciseDetails;
27 |
--------------------------------------------------------------------------------
/backend/core/admin.py:
--------------------------------------------------------------------------------
1 | from django.contrib import admin
2 |
3 | from core.models import Profile, Exercise, Workout, WorkoutExercise, WorkoutExerciseDetail
4 |
5 |
6 | class ProfileAdmin(admin.ModelAdmin):
7 | list_display = ('id', 'user', 'weight_system')
8 |
9 |
10 | class ExerciseAdmin(admin.ModelAdmin):
11 | list_display = ('id', 'body_part', 'name', 'equipment', 'description', 'user')
12 |
13 |
14 | class WorkoutAdmin(admin.ModelAdmin):
15 | list_display = ('id', 'user', 'status', 'created', 'modified')
16 |
17 |
18 | class WorkoutExerciseAdmin(admin.ModelAdmin):
19 | list_display = ('id', 'workout', 'exercise')
20 |
21 |
22 | class WorkoutExerciseDetailAdmin(admin.ModelAdmin):
23 | list_display = ('id', 'workout_exercise', 'sets', 'reps', 'weight')
24 |
25 |
26 | admin.site.register(Profile, ProfileAdmin)
27 | admin.site.register(Exercise, ExerciseAdmin)
28 | admin.site.register(Workout, WorkoutAdmin)
29 | admin.site.register(WorkoutExercise, WorkoutExerciseAdmin)
30 | admin.site.register(WorkoutExerciseDetail, WorkoutExerciseDetailAdmin)
31 |
--------------------------------------------------------------------------------
/backend/backend/settings/prod.py:
--------------------------------------------------------------------------------
1 | import os
2 | import dj_database_url
3 | from backend.settings.base import *
4 |
5 | DEBUG = False
6 |
7 | INSTALLED_APPS += [
8 | 'whitenoise.runserver_nostatic',
9 | ]
10 |
11 | MIDDLEWARE += [
12 | 'whitenoise.middleware.WhiteNoiseMiddleware',
13 | ]
14 |
15 | DATABASE_URL = os.environ.get('DATABASE_URL')
16 | DATABASES['default'] = dj_database_url.config(default=DATABASE_URL, conn_max_age=500, ssl_require=True)
17 |
18 | TEMPLATES[0]["DIRS"] = [os.path.join(BASE_DIR, "frontend", "build")]
19 | WHITENOISE_ROOT = os.path.join(BASE_DIR, "frontend", "build", "root")
20 |
21 | STATICFILES_STORAGE = "whitenoise.storage.CompressedStaticFilesStorage"
22 | STATIC_ROOT = os.path.join(BASE_DIR, "staticfiles")
23 | STATICFILES_DIRS = [os.path.join(BASE_DIR, "frontend", "build", "static")]
24 |
25 | CSRF_TRUSTED_ORIGINS = ['https://*.127.0.0.1', os.environ.get("HOST_NAME")]
26 |
27 | SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https')
28 | SECURE_SSL_REDIRECT = True
29 | SESSION_COOKIE_SECURE = True
30 | CSRF_COOKIE_SECURE = True
31 |
--------------------------------------------------------------------------------
/frontend/src/redux/store.js:
--------------------------------------------------------------------------------
1 | import {configureStore} from '@reduxjs/toolkit';
2 | import storage from 'redux-persist/lib/storage';
3 | import {combineReducers} from 'redux';
4 | import {persistReducer, persistStore} from 'redux-persist';
5 | import thunk from 'redux-thunk';
6 | import authReducer from './slices/authSlice';
7 | import workoutReducer from './slices/workoutSlice';
8 |
9 |
10 | const reducers = combineReducers({
11 | auth: authReducer,
12 | workout: workoutReducer
13 | });
14 |
15 | const rootReducer = (state, action) => {
16 | // Clear all data in redux store to initial.
17 | if (action.type === "CLEAR_SESSION") {
18 | state = undefined;
19 | localStorage.clear();
20 | }
21 |
22 | return reducers(state, action);
23 | };
24 |
25 | const persistConfig = {
26 | key: 'root',
27 | storage
28 | };
29 |
30 | const persistedReducer = persistReducer(persistConfig, rootReducer);
31 |
32 | const store = configureStore({
33 | reducer: persistedReducer,
34 | middleware: [thunk],
35 | });
36 |
37 | export const persistor = persistStore(store);
38 | export default store;
--------------------------------------------------------------------------------
/backend/backend/urls.py:
--------------------------------------------------------------------------------
1 | from django.conf import settings
2 | from django.contrib import admin
3 | from django.urls import path, include
4 | from drf_yasg import openapi
5 | from drf_yasg.views import get_schema_view
6 | from rest_framework.routers import DefaultRouter
7 |
8 | from auth.urls import auth_router
9 | from core.urls import core_router
10 | from user.views import UserViewSet
11 |
12 | router = DefaultRouter()
13 | router.registry.extend(auth_router.registry)
14 | router.registry.extend(core_router.registry)
15 | router.register(r'user', UserViewSet, basename='user')
16 |
17 | schema_view = get_schema_view(openapi.Info(title="Gym Log API", default_version='v1'), public=True)
18 |
19 | urlpatterns = [
20 | path('admin/', admin.site.urls),
21 | path("api/", include((router.urls, 'api'))),
22 | path('swagger/', schema_view.with_ui('swagger', cache_timeout=0), name='schema-swagger-ui'),
23 | ]
24 |
25 | if not settings.DEBUG:
26 | from django.urls import re_path
27 | from django.views.generic import TemplateView
28 |
29 | # In production serve static files for React application from the root path.
30 | urlpatterns += re_path(".*", TemplateView.as_view(template_name="index.html")),
31 |
--------------------------------------------------------------------------------
/frontend/src/components/Exercises/FilterExercises/SelectExercises.js:
--------------------------------------------------------------------------------
1 | import ListGroup from "react-bootstrap/ListGroup";
2 | import Form from 'react-bootstrap/Form'
3 |
4 |
5 | const SelectExercises = ({exercises, handleSelect}) => {
6 | return (
7 |
25 | );
26 | };
27 |
28 | export default SelectExercises;
29 |
--------------------------------------------------------------------------------
/frontend/src/styles/index.css:
--------------------------------------------------------------------------------
1 | body {
2 | margin: 0;
3 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Roboto", "Oxygen",
4 | "Ubuntu", "Cantarell", "Fira Sans", "Droid Sans", "Helvetica Neue",
5 | sans-serif;
6 | -webkit-font-smoothing: antialiased;
7 | -moz-osx-font-smoothing: grayscale;
8 | min-width: 400px;
9 | position: relative;
10 | }
11 |
12 | code {
13 | font-family: source-code-pro, Menlo, Monaco, Consolas, "Courier New",
14 | monospace;
15 | }
16 |
17 | .App {
18 | height: 100vh;
19 | display: flex;
20 | flex-direction: column;
21 | justify-content: space-between;
22 | }
23 |
24 | .main {
25 | flex-grow: 2;
26 | }
27 |
28 | .view-scroll-container {
29 | max-height: 65vh;
30 | overflow: auto;
31 | }
32 |
33 | .select-scroll-container {
34 | max-height: 60vh;
35 | overflow: auto;
36 | }
37 |
38 | .list-workout-exercises-scroll-container {
39 | max-height: 60vh;
40 | overflow: auto;
41 | }
42 |
43 | .workout {
44 | border: 1px solid lightgrey !important;
45 | border-radius: 0.5em !important;
46 | }
47 |
48 | .btn-flex-container {
49 | display: flex;
50 | justify-content: flex-end;
51 | align-items: center;
52 | }
53 |
54 | .flex-grow-2 {
55 | flex-grow: 2;
56 | }
57 |
58 | a:link {
59 | text-decoration: none;
60 | }
61 |
62 | a:hover {
63 | color: grey;
64 | }
--------------------------------------------------------------------------------
/backend/.gitignore:
--------------------------------------------------------------------------------
1 | # Byte-compiled / optimized / DLL files
2 | __pycache__/
3 | *.py[cod]
4 | *$py.class
5 | *.pyc
6 | /backend/db.sqlite3
7 | .DS_Store
8 | /staticfiles/*
9 | /mediafiles/*
10 | /.vscode/
11 |
12 | # C extensions
13 | *.so
14 |
15 | # Distribution / packaging
16 | .Python
17 | build/
18 | develop-eggs/
19 | dist/
20 | downloads/
21 | eggs/
22 | .eggs/
23 | lib/
24 | lib64/
25 | parts/
26 | sdist/
27 | var/
28 | wheels/
29 | pip-wheel-metadata/
30 | share/python-wheels/
31 | *.egg-info/
32 | .installed.cfg
33 | *.egg
34 | MANIFEST
35 |
36 | # PyInstaller
37 | # Usually these files are written by a python script from a template
38 | # before PyInstaller builds the exe, so as to inject date/other infos into it.
39 | *.manifest
40 | *.spec
41 |
42 | # Installer logs
43 | pip-log.txt
44 | pip-delete-this-directory.txt
45 |
46 | # Unit test / coverage reports
47 | htmlcov/
48 | .tox/
49 | .nox/
50 | .coverage
51 | .coverage.*
52 | .cache
53 | nosetests.xml
54 | coverage.xml
55 | *.cover
56 | *.py,cover
57 | .hypothesis/
58 | .pytest_cache/
59 |
60 | # Django stuff:
61 | *.log
62 | local_settings.py
63 | db.sqlite3
64 | db.sqlite3-journal
65 |
66 | # pyenv
67 | .python-version
68 |
69 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow
70 | __pypackages__/
71 |
72 | # Environments
73 | .env.example
74 | .venv
75 | env/
76 | venv/
77 | ENV/
78 | env.bak/
79 | venv.bak/
80 |
81 | # pycharm
82 | .idea
--------------------------------------------------------------------------------
/backend/auth/serializers.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.models import update_last_login
2 | from django.core.exceptions import ObjectDoesNotExist
3 | from rest_framework import serializers
4 | from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
5 | from rest_framework_simplejwt.settings import api_settings
6 |
7 | from user.models import User
8 | from user.serializers import UserSerializer
9 |
10 |
11 | class RegisterSerializer(UserSerializer):
12 | password = serializers.CharField(max_length=128, min_length=8, write_only=True, required=True)
13 | email = serializers.EmailField(required=True, max_length=128)
14 |
15 | class Meta:
16 | model = User
17 | fields = ['id', 'username', 'email', 'password', 'created', 'modified']
18 |
19 | def create(self, validated_data):
20 | try:
21 | user = User.objects.get(email=validated_data['email'])
22 | except ObjectDoesNotExist:
23 | user = User.objects.create_user(**validated_data)
24 | return user
25 |
26 |
27 | class LoginSerializer(TokenObtainPairSerializer):
28 | def validate(self, attrs):
29 | data = super().validate(attrs)
30 | refresh = self.get_token(self.user)
31 |
32 | data['access'] = str(refresh.access_token)
33 | data['refresh'] = str(refresh)
34 | data['user'] = UserSerializer(self.user).data
35 |
36 | if api_settings.UPDATE_LAST_LOGIN:
37 | update_last_login(None, self.user)
38 |
39 | return data
40 |
--------------------------------------------------------------------------------
/backend/user/models.py:
--------------------------------------------------------------------------------
1 | from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin, BaseUserManager
2 | from django.db import models
3 |
4 | from common.models import IndexedTimeStampedModel
5 |
6 |
7 | class UserManager(BaseUserManager):
8 | def create_user(self, username, email, password=None, **kwargs):
9 | email = self.normalize_email(email)
10 | user = self.model(username=username, email=email, **kwargs)
11 | user.set_password(password)
12 | user.save(using=self._db)
13 | return user
14 |
15 | def create_superuser(self, username, email, password):
16 | user = self.create_user(username, email, password)
17 | user.is_superuser = True
18 | user.is_staff = True
19 | user.save(using=self._db)
20 | return user
21 |
22 |
23 | class User(AbstractBaseUser, PermissionsMixin, IndexedTimeStampedModel):
24 | username = models.CharField(db_index=True, max_length=255, unique=True)
25 | email = models.EmailField(db_index=True, max_length=255, unique=True)
26 | is_staff = models.BooleanField(
27 | default=False, help_text="Designates whether the user can log into this admin site."
28 | )
29 | is_active = models.BooleanField(
30 | default=True, help_text="Designates whether this user should be treated as active.")
31 |
32 | objects = UserManager()
33 |
34 | USERNAME_FIELD = "email"
35 | REQUIRED_FIELDS = ["username"]
36 |
37 | def __str__(self):
38 | return f'User(email={self.email}, username={self.username})'
39 |
--------------------------------------------------------------------------------
/frontend/package.json:
--------------------------------------------------------------------------------
1 | {
2 | "name": "frontend",
3 | "version": "0.1.0",
4 | "private": true,
5 | "proxy": "http://localhost:8000/",
6 | "dependencies": {
7 | "@reduxjs/toolkit": "^1.8.2",
8 | "@testing-library/jest-dom": "^5.16.2",
9 | "@testing-library/react": "^12.1.3",
10 | "@testing-library/user-event": "^13.5.0",
11 | "axios": "^0.26.1",
12 | "axios-auth-refresh": "^3.3.1",
13 | "bootstrap": "^5.1.3",
14 | "formik": "^2.2.9",
15 | "prop-types": "^15.8.1",
16 | "react": "^17.0.2",
17 | "react-bootstrap": "^2.2.0",
18 | "react-dom": "^17.0.2",
19 | "react-icons": "^4.3.1",
20 | "react-query": "^3.39.1",
21 | "react-redux": "^8.0.2",
22 | "react-router-dom": "^6.2.2",
23 | "react-scripts": "5.0.0",
24 | "react-toastify": "^8.2.0",
25 | "redux": "^4.2.0",
26 | "redux-persist": "^6.0.0",
27 | "redux-thunk": "^2.4.1",
28 | "web-vitals": "^2.1.4",
29 | "yup": "^0.32.11"
30 | },
31 | "scripts": {
32 | "start": "react-scripts start",
33 | "build": "react-scripts build",
34 | "test": "react-scripts test",
35 | "eject": "react-scripts eject"
36 | },
37 | "eslintConfig": {
38 | "extends": [
39 | "react-app",
40 | "react-app/jest"
41 | ]
42 | },
43 | "browserslist": {
44 | "production": [
45 | ">0.2%",
46 | "not dead",
47 | "not op_mini all"
48 | ],
49 | "development": [
50 | "last 1 chrome version",
51 | "last 1 firefox version",
52 | "last 1 safari version"
53 | ]
54 | }
55 | }
56 |
--------------------------------------------------------------------------------
/frontend/src/api/instances/axiosInstance.js:
--------------------------------------------------------------------------------
1 | import axios from 'axios'
2 | import createAuthRefreshInterceptor from 'axios-auth-refresh';
3 | import {apiRoutes} from "../../utils/routes";
4 | import store from "../../redux/store";
5 |
6 | const isDevEnv = !process.env.NODE_ENV || process.env.NODE_ENV === 'development';
7 |
8 | const axiosInstance = axios.create({
9 | baseURL: isDevEnv ? "http://localhost:8000/api" : "api",
10 | timeout: 5000,
11 | headers: {
12 | 'Authorization': "JWT " + localStorage.getItem('accessToken'),
13 | 'Content-Type': 'application/json',
14 | 'accept': 'application/json'
15 | }
16 | });
17 |
18 |
19 | const refreshAuth = failedRequest =>
20 | axiosInstance
21 | .post(apiRoutes.refresh, {"refresh": localStorage.getItem('refreshToken')})
22 | .then(response => {
23 | localStorage.setItem('accessToken', response.data.access);
24 | localStorage.setItem('refreshToken', response.data.refresh);
25 |
26 | axiosInstance.defaults.headers['Authorization'] = "JWT " + response.data.access;
27 | failedRequest.response.config.headers['Authorization'] = 'JWT ' + response.data.access;
28 | return Promise.resolve();
29 | })
30 | .catch(error => {
31 | console.error("Could not refresh token", error);
32 | store.dispatch({type: "CLEAR_SESSION"});
33 | return Promise.reject(error);
34 | });
35 |
36 |
37 | createAuthRefreshInterceptor(axiosInstance, refreshAuth);
38 | export default axiosInstance;
--------------------------------------------------------------------------------
/frontend/src/components/Exercises/index.js:
--------------------------------------------------------------------------------
1 | import {useState} from "react";
2 | import Row from "react-bootstrap/Row";
3 | import Col from "react-bootstrap/Col";
4 | import Stack from "react-bootstrap/Stack";
5 | import Button from "react-bootstrap/Button";
6 | import NewExerciseModal from "./NewExerciseModal";
7 | import FilterExercises from "./FilterExercises";
8 | import ViewExercises from "./FilterExercises/ViewExercises";
9 |
10 |
11 | const Exercises = () => {
12 | const [showNewExerciseModal, setShowNewExerciseModal] = useState(false);
13 |
14 | return (
15 |
16 | setShowNewExerciseModal(false)}
19 | />
20 |
21 |
22 |
23 |
24 | Exercises
25 |
26 |
27 | setShowNewExerciseModal(true)}
32 | >
33 | New
34 |
35 |
36 |
37 |
38 |
39 |
40 |
41 | );
42 | };
43 |
44 | export default Exercises;
45 |
--------------------------------------------------------------------------------
/backend/core/views.py:
--------------------------------------------------------------------------------
1 | from django.db.models import Q
2 | from rest_framework import viewsets, status
3 | from rest_framework.decorators import action
4 | from rest_framework.permissions import IsAuthenticatedOrReadOnly, IsAuthenticated
5 | from rest_framework.response import Response
6 |
7 | from core.models import Exercise, Workout, Profile
8 | from core.permissions import IsOwner
9 | from core.serializers import ExerciseSerializer, WorkoutSerializer, ProfileSerializer
10 |
11 |
12 | class ProfileViewSet(viewsets.ModelViewSet):
13 | permission_classes = (IsAuthenticated, IsOwner)
14 | http_method_names = ['get', 'post']
15 | serializer_class = ProfileSerializer
16 | queryset = Profile.objects.all()
17 |
18 | def get_queryset(self):
19 | return self.queryset.filter(user=self.request.user.id)
20 |
21 |
22 | class ExerciseViewSet(viewsets.ModelViewSet):
23 | permission_classes = (IsAuthenticatedOrReadOnly,)
24 | http_method_names = ['get', 'post', 'delete']
25 | serializer_class = ExerciseSerializer
26 | queryset = Exercise.objects.all()
27 |
28 | def get_queryset(self):
29 | return self.queryset.filter(Q(user=self.request.user.id) | Q(user=None)).order_by('name')
30 |
31 | @action(detail=False, methods=['get'])
32 | def body_parts(self, request):
33 | return Response(Exercise.body_part_list, status=status.HTTP_200_OK)
34 |
35 | @action(detail=False, methods=['get'])
36 | def equipment(self, request):
37 | return Response(Exercise.equipment_list, status=status.HTTP_200_OK)
38 |
39 |
40 | class WorkoutViewSet(viewsets.ModelViewSet):
41 | permission_classes = (IsAuthenticated, IsOwner,)
42 | http_method_names = ['get', 'post', 'delete']
43 | serializer_class = WorkoutSerializer
44 | queryset = Workout.objects.all().order_by('-created')
45 |
46 | def get_queryset(self):
47 | return self.queryset.filter(user=self.request.user.id)
48 |
--------------------------------------------------------------------------------
/frontend/src/components/Workout/NewWorkout/ListWorkoutExercises.js:
--------------------------------------------------------------------------------
1 | import {useDispatch, useSelector} from "react-redux";
2 | import {ListGroup} from "react-bootstrap";
3 | import Row from "react-bootstrap/Row";
4 | import Col from "react-bootstrap/Col";
5 | import Button from "react-bootstrap/Button";
6 | import AddWorkoutExerciseDetails from "./AddWorkoutExerciseDetails";
7 | import {deleteExercise} from "../../../redux/slices/workoutSlice";
8 |
9 |
10 | const ListWorkoutExercises = () => {
11 | const exercises = useSelector((state) => state.workout.exercises);
12 | const dispatch = useDispatch();
13 |
14 | return (
15 |
16 |
Exercises:
17 |
18 | {exercises?.map((exercise, index) =>
19 |
20 |
21 |
22 | {exercise.name} ({exercise.equipment})
23 |
24 |
25 | dispatch(deleteExercise(index))}
30 | >
31 | Delete Exercise
32 |
33 |
34 |
35 |
36 |
37 | )}
38 |
39 |
40 | );
41 | };
42 |
43 | export default ListWorkoutExercises;
44 |
--------------------------------------------------------------------------------
/frontend/src/components/NavigationBar.js:
--------------------------------------------------------------------------------
1 | import {useEffect, useState} from "react";
2 | import {Link, useLocation} from "react-router-dom";
3 | import {useSelector} from "react-redux";
4 | import Navbar from "react-bootstrap/Navbar";
5 | import Nav from "react-bootstrap/Nav";
6 | import {pageRoutes} from "../utils/routes";
7 |
8 | const NavigationBar = () => {
9 | const location = useLocation();
10 | const [activeTab, setActiveTab] = useState(location.pathname);
11 | const authenticated = useSelector((state) => state.auth.authenticated)
12 |
13 | useEffect(() => {
14 | // Location is needed to highlight the navigation link if page is reloaded by the user.
15 | setActiveTab(location.pathname);
16 | }, [location]);
17 |
18 | return authenticated && (
19 |
20 | setActiveTab(value)}
26 | >
27 |
28 |
29 |
30 | Profile
31 |
32 |
33 |
34 |
35 |
36 | Workout
37 |
38 |
39 |
40 |
41 |
42 | History
43 |
44 |
45 |
46 |
47 |
48 | Exercises
49 |
50 |
51 |
52 |
53 |
54 | );
55 | };
56 |
57 | export default NavigationBar;
58 |
--------------------------------------------------------------------------------
/frontend/public/index.html:
--------------------------------------------------------------------------------
1 |
2 |
3 |
4 |
5 |
6 |
7 |
8 |
12 |
13 |
17 |
18 |
19 |
28 | Gym Log
29 |
30 |
31 | You need to enable JavaScript to run this app.
32 |
33 |
43 |
44 |
45 |
--------------------------------------------------------------------------------
/frontend/src/components/Workout/NewWorkout/AddExercisesModal.js:
--------------------------------------------------------------------------------
1 | import {useState} from "react";
2 | import {useDispatch} from "react-redux";
3 | import {toast} from "react-toastify";
4 | import Modal from "react-bootstrap/Modal";
5 | import Button from "react-bootstrap/Button";
6 | import FilterExercises from "../../Exercises/FilterExercises";
7 | import SelectExercises from "../../Exercises/FilterExercises/SelectExercises";
8 | import {addExercises} from "../../../redux/slices/workoutSlice";
9 |
10 |
11 | const AddExercisesModal = (props) => {
12 | const [selectedExercises, setSelectedExercises] = useState([]);
13 | const dispatch = useDispatch()
14 |
15 | const close = () => {
16 | setSelectedExercises([]);
17 | props.onHide();
18 | }
19 |
20 | const handleSelect = (e) => {
21 | const {id, checked} = e.target;
22 | if (checked) {
23 | setSelectedExercises(prevState => [...prevState, parseInt(id)])
24 | } else {
25 | setSelectedExercises(prevState => prevState.filter(i => parseInt(i) !== id))
26 | }
27 | }
28 |
29 | const handleAddExercises = () => {
30 | if (selectedExercises.length < 1) {
31 | toast.error("You did not selected an Exercise!");
32 | return;
33 | }
34 | dispatch(addExercises(selectedExercises));
35 | close();
36 | }
37 |
38 | return (
39 |
40 |
41 |
42 | Select an exercise
43 |
44 |
45 |
46 |
47 |
48 |
49 | Cancel
50 | Add
51 |
52 |
53 |
54 | );
55 | };
56 |
57 | export default AddExercisesModal;
58 |
--------------------------------------------------------------------------------
/frontend/src/redux/slices/workoutSlice.js:
--------------------------------------------------------------------------------
1 | import {createSlice} from '@reduxjs/toolkit';
2 | import {queryClient} from "../../api/instances/queryClient";
3 |
4 |
5 | const initialState = {
6 | workoutStarted: false,
7 | exercises: [],
8 | }
9 |
10 | const initialExerciseDetails = {
11 | sets: 0,
12 | reps: 0,
13 | weight: 0
14 | }
15 |
16 | export const workoutSlice = createSlice({
17 | name: 'workout',
18 | initialState,
19 | reducers: {
20 | startWorkout: (state) => {
21 | state.workoutStarted = true
22 | },
23 | clearWorkout: () => {
24 | return initialState;
25 | },
26 | addExercises: (state, action) => {
27 | const exercises = queryClient.getQueryData("exercise");
28 | const exercisesToAdd = action.payload.map(id => exercises.find(exercise => exercise.id === id));
29 | exercisesToAdd.map(exercise => exercise["workout_exercise_details"] = [initialExerciseDetails])
30 | exercisesToAdd.map(exercise => exercise["exercise"] = exercise.id)
31 | state.exercises.push(...exercisesToAdd);
32 | },
33 | deleteExercise: (state, action) => {
34 | state.exercises.splice(action.payload, 1);
35 | },
36 | addExerciseDetails: (state, action) => {
37 | state.exercises[action.payload]["workout_exercise_details"].push(initialExerciseDetails);
38 | },
39 | updateExerciseDetails: (state, action) => {
40 | const {exerciseIndex, index, name, value} = action.payload;
41 | state.exercises[exerciseIndex]["workout_exercise_details"][index][name] = value;
42 | },
43 | deleteExerciseDetails: (state, action) => {
44 | const {exerciseIndex, index} = action.payload;
45 | state.exercises[exerciseIndex]["workout_exercise_details"].splice(index, 1);
46 | },
47 | },
48 | })
49 |
50 | export const {
51 | startWorkout,
52 | clearWorkout,
53 | addExercises,
54 | deleteExercise,
55 | addExerciseDetails,
56 | updateExerciseDetails,
57 | deleteExerciseDetails
58 | } = workoutSlice.actions;
59 |
60 | export default workoutSlice.reducer;
--------------------------------------------------------------------------------
/frontend/src/components/Workout/NewWorkout/index.js:
--------------------------------------------------------------------------------
1 | import {useState} from "react";
2 | import {useDispatch, useSelector} from "react-redux";
3 | import {useMutation} from "react-query";
4 | import {toast} from "react-toastify";
5 | import Button from "react-bootstrap/Button";
6 | import AddExercisesModal from "./AddExercisesModal";
7 | import ListWorkoutExercises from "./ListWorkoutExercises";
8 | import {timestampToString} from "../../../utils/helpers";
9 | import {clearWorkout} from "../../../redux/slices/workoutSlice";
10 | import * as api from "../../../api/workoutApi";
11 |
12 |
13 | const NewWorkout = () => {
14 | const [showExerciseModal, setShowExerciseModal] = useState(false);
15 | const exercises = useSelector((state) => state.workout.exercises);
16 | const dispatch = useDispatch();
17 |
18 | const createWorkoutMutation = useMutation(api.createWorkout, {
19 | onSuccess: () => {
20 | dispatch(clearWorkout());
21 | toast.success("Saved a new workout!");
22 | },
23 | onError: (error) => {
24 | console.error(error);
25 | toast.error("Could not save a new workout!");
26 | },
27 | });
28 |
29 | const handleSaveWorkout = () => {
30 | const payload = {
31 | "status": "Finished",
32 | "workout_exercises": exercises
33 | }
34 | createWorkoutMutation.mutate(payload);
35 | }
36 |
37 | return (
38 |
39 |
New workout
40 |
41 | Status: Started | {timestampToString(new Date())}
42 |
43 |
44 |
45 |
46 |
47 | setShowExerciseModal(true)}>Add Exercises
48 | dispatch(clearWorkout())}>Cancel Workout
49 | Save Workout
50 |
51 |
52 |
setShowExerciseModal(false)}
55 | />
56 |
57 | );
58 | };
59 |
60 | export default NewWorkout;
--------------------------------------------------------------------------------
/backend/user/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.0.4 on 2022-06-24 06:44
2 |
3 | from django.db import migrations, models
4 | import django.utils.timezone
5 | import model_utils.fields
6 |
7 |
8 | class Migration(migrations.Migration):
9 |
10 | initial = True
11 |
12 | dependencies = [
13 | ('auth', '0012_alter_user_first_name_max_length'),
14 | ]
15 |
16 | operations = [
17 | migrations.CreateModel(
18 | name='User',
19 | fields=[
20 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
21 | ('password', models.CharField(max_length=128, verbose_name='password')),
22 | ('last_login', models.DateTimeField(blank=True, null=True, verbose_name='last login')),
23 | ('is_superuser', models.BooleanField(default=False, help_text='Designates that this user has all permissions without explicitly assigning them.', verbose_name='superuser status')),
24 | ('created', model_utils.fields.AutoCreatedField(db_index=True, default=django.utils.timezone.now, editable=False, verbose_name='created')),
25 | ('modified', model_utils.fields.AutoLastModifiedField(db_index=True, default=django.utils.timezone.now, editable=False, verbose_name='modified')),
26 | ('username', models.CharField(db_index=True, max_length=255, unique=True)),
27 | ('email', models.EmailField(db_index=True, max_length=255, unique=True)),
28 | ('is_staff', models.BooleanField(default=False, help_text='Designates whether the user can log into this admin site.')),
29 | ('is_active', models.BooleanField(default=True, help_text='Designates whether this user should be treated as active.')),
30 | ('groups', models.ManyToManyField(blank=True, help_text='The groups this user belongs to. A user will get all permissions granted to each of their groups.', related_name='user_set', related_query_name='user', to='auth.group', verbose_name='groups')),
31 | ('user_permissions', models.ManyToManyField(blank=True, help_text='Specific permissions for this user.', related_name='user_set', related_query_name='user', to='auth.permission', verbose_name='user permissions')),
32 | ],
33 | options={
34 | 'abstract': False,
35 | },
36 | ),
37 | ]
38 |
--------------------------------------------------------------------------------
/backend/auth/views.py:
--------------------------------------------------------------------------------
1 | from rest_framework import viewsets, status
2 | from rest_framework.permissions import AllowAny
3 | from rest_framework.response import Response
4 | from rest_framework.viewsets import ModelViewSet
5 | from rest_framework_simplejwt.exceptions import TokenError, InvalidToken
6 | from rest_framework_simplejwt.tokens import RefreshToken
7 | from rest_framework_simplejwt.views import TokenRefreshView, TokenObtainPairView, TokenBlacklistView
8 |
9 | from auth.serializers import LoginSerializer, RegisterSerializer
10 |
11 |
12 | class RegistrationViewSet(ModelViewSet, TokenObtainPairView):
13 | permission_classes = (AllowAny,)
14 | http_method_names = ['post']
15 | serializer_class = RegisterSerializer
16 |
17 | def create(self, request, *args, **kwargs):
18 | serializer = self.get_serializer(data=request.data)
19 |
20 | serializer.is_valid(raise_exception=True)
21 | user = serializer.save()
22 | refresh = RefreshToken.for_user(user)
23 |
24 | res = {
25 | "access": str(refresh.access_token),
26 | "refresh": str(refresh),
27 | "user": serializer.data,
28 | }
29 |
30 | return Response(res, status=status.HTTP_201_CREATED)
31 |
32 |
33 | class LoginViewSet(ModelViewSet, TokenObtainPairView):
34 | permission_classes = (AllowAny,)
35 | http_method_names = ['post']
36 | serializer_class = LoginSerializer
37 |
38 | def create(self, request, *args, **kwargs):
39 | serializer = self.get_serializer(data=request.data)
40 |
41 | try:
42 | serializer.is_valid(raise_exception=True)
43 | except TokenError as e:
44 | raise InvalidToken(e.args[0])
45 |
46 | return Response(serializer.validated_data, status=status.HTTP_200_OK)
47 |
48 |
49 | class RefreshViewSet(viewsets.ViewSet, TokenRefreshView):
50 | def create(self, request, *args, **kwargs):
51 | serializer = self.get_serializer(data=request.data)
52 |
53 | try:
54 | serializer.is_valid(raise_exception=True)
55 | except TokenError as e:
56 | raise InvalidToken(e.args[0])
57 |
58 | return Response(serializer.validated_data, status=status.HTTP_200_OK)
59 |
60 |
61 | class BlacklistTokenViewSet(viewsets.ViewSet, TokenBlacklistView):
62 | def create(self, request, *args, **kwargs):
63 | serializer = self.get_serializer(data=request.data)
64 |
65 | try:
66 | serializer.is_valid(raise_exception=True)
67 | except TokenError as e:
68 | raise InvalidToken(e.args[0])
69 |
70 | return Response(serializer.validated_data, status=status.HTTP_205_RESET_CONTENT)
71 |
--------------------------------------------------------------------------------
/frontend/src/components/History/index.js:
--------------------------------------------------------------------------------
1 | import ListGroup from "react-bootstrap/ListGroup";
2 | import {useMutation, useQuery, useQueryClient} from "react-query";
3 | import {toast} from "react-toastify";
4 | import Row from "react-bootstrap/Row";
5 | import Col from "react-bootstrap/Col";
6 | import Button from "react-bootstrap/Button";
7 | import WorkoutExercise from "./WorkoutExercise";
8 | import * as api from "../../api/workoutApi";
9 | import {timestampToString} from "../../utils/helpers";
10 | import {Link} from "react-router-dom";
11 | import {pageRoutes} from "../../utils/routes";
12 |
13 |
14 | const WorkoutHistory = () => {
15 | const {data} = useQuery('workout', api.getWorkoutHistory);
16 | const queryClient = useQueryClient()
17 |
18 | const deleteWorkoutMutation = useMutation(api.deleteWorkout, {
19 | onError: (error) => {
20 | console.error(error);
21 | toast.error("Could not delete workout!");
22 | },
23 | onSuccess: () => {
24 | toast.info("Workout has been deleted!");
25 | queryClient.invalidateQueries("workout");
26 | },
27 | })
28 |
29 | return (
30 |
31 |
History
32 |
33 | {data?.length === 0 &&
34 |
35 |
You don't have any saved workouts!
36 |
Start your first workout!
37 |
38 | }
39 |
40 |
41 | {data?.map(workout =>
42 |
43 |
44 |
45 | Workout
46 |
47 |
48 | deleteWorkoutMutation.mutate(workout.id)}>
52 |
53 |
54 |
55 |
56 |
57 | Status: Status: {workout.status} | {timestampToString(workout.created)}
58 |
59 |
60 |
61 | )}
62 |
63 |
64 | );
65 | };
66 |
67 | export default WorkoutHistory;
68 |
--------------------------------------------------------------------------------
/backend/core/serializers.py:
--------------------------------------------------------------------------------
1 | from rest_framework import serializers
2 |
3 | from core.models import Profile, Exercise, Workout, WorkoutExercise, WorkoutExerciseDetail
4 |
5 |
6 | class ProfileSerializer(serializers.ModelSerializer):
7 | username = serializers.CharField(source='user.username', read_only=True)
8 | email = serializers.CharField(source='user.email', read_only=True)
9 | created = serializers.DateTimeField(source='user.created', read_only=True)
10 |
11 | class Meta:
12 | model = Profile
13 | fields = ['id', 'username', 'email', 'weight_system', 'created']
14 |
15 | def create(self, validated_data):
16 | request_user = self.context['request'].user
17 | instance = Profile.objects.get(user=request_user)
18 | instance.weight_system = validated_data.get("weight_system")
19 | instance.save()
20 | return instance
21 |
22 |
23 | class ExerciseSerializer(serializers.ModelSerializer):
24 | class Meta:
25 | model = Exercise
26 | fields = '__all__'
27 |
28 | def create(self, validated_data):
29 | request_user = self.context['request'].user
30 | instance = Exercise.objects.create(user=request_user, **validated_data)
31 | return instance
32 |
33 |
34 | class WorkoutExerciseDetailSerializer(serializers.ModelSerializer):
35 | class Meta:
36 | model = WorkoutExerciseDetail
37 | fields = ['id', 'sets', 'reps', 'weight']
38 |
39 |
40 | class WorkoutExerciseSerializer(serializers.ModelSerializer):
41 | name = serializers.CharField(source='exercise.name', read_only=True)
42 | workout_exercise_details = WorkoutExerciseDetailSerializer(many=True, required=False)
43 |
44 | class Meta:
45 | model = WorkoutExercise
46 | fields = ['id', 'exercise', 'name', 'workout_exercise_details']
47 |
48 |
49 | class WorkoutSerializer(serializers.ModelSerializer):
50 | workout_exercises = WorkoutExerciseSerializer(many=True, required=False)
51 |
52 | class Meta:
53 | model = Workout
54 | fields = ['id', 'status', 'created', 'modified', 'workout_exercises']
55 |
56 | def create(self, validated_data):
57 | """Writable Nested Model Serializer"""
58 | request_user = self.context['request'].user
59 | instance = Workout.objects.create(user=request_user, status=validated_data.pop('status'))
60 |
61 | if 'workout_exercises' in validated_data:
62 | for data in validated_data.pop('workout_exercises'):
63 | exercise = data.get('exercise')
64 | workout_exercise = WorkoutExercise.objects.create(workout=instance, exercise=exercise)
65 |
66 | if 'workout_exercise_details' in data:
67 | for exercise_details in data.get('workout_exercise_details'):
68 | WorkoutExerciseDetail.objects.create(workout_exercise=workout_exercise, **exercise_details)
69 |
70 | return instance
71 |
--------------------------------------------------------------------------------
/frontend/src/App.js:
--------------------------------------------------------------------------------
1 | import {BrowserRouter as Router, Route, Routes, Navigate} from "react-router-dom";
2 | import {useDispatch, useSelector} from "react-redux";
3 | import {useQuery} from "react-query";
4 | import {ToastContainer} from "react-toastify";
5 | import Container from "react-bootstrap/Container";
6 | import NavigationBar from "./components/NavigationBar";
7 | import Footer from "./components/Footer";
8 | import Login from "./components/Login";
9 | import Register from "./components/Register";
10 | import Profile from "./components/Profile";
11 | import Workout from "./components/Workout";
12 | import Exercises from "./components/Exercises";
13 | import WorkoutHistory from "./components/History";
14 | import ProtectedRoute from "./components/ProtectedRoute";
15 | import {updateWeightUnit} from "./redux/slices/authSlice";
16 | import {pageRoutes} from "./utils/routes";
17 | import * as exerciseApi from "./api/exerciseApi";
18 | import * as profileApi from "./api/profileApi";
19 |
20 |
21 | const App = () => {
22 | const dispatch = useDispatch();
23 | const authenticated = useSelector((state) => state.auth.authenticated);
24 | // Fetch initial data that will be used by several components
25 | useQuery('bodyParts', exerciseApi.getBodyParts, {staleTime: Infinity, enabled: authenticated});
26 | useQuery('equipment', exerciseApi.getEquipment, {staleTime: Infinity, enabled: authenticated});
27 | useQuery('profile', profileApi.getProfile, {
28 | onSuccess: (data) => dispatch(updateWeightUnit(data["weight_system"])),
29 | enabled: authenticated
30 | });
31 |
32 | return (
33 |
34 |
35 |
36 |
37 |
38 | }/>
39 | }/>
40 | }/>
41 | }/>
42 | }/>
43 | }/>
44 | : }/>
45 |
46 |
47 |
48 |
49 |
50 |
59 |
60 | );
61 | };
62 |
63 | export default App;
64 |
--------------------------------------------------------------------------------
/frontend/src/components/Exercises/FilterExercises/index.js:
--------------------------------------------------------------------------------
1 | import {useEffect, useState} from "react";
2 | import {useQuery, useQueryClient} from "react-query";
3 | import Row from "react-bootstrap/Row";
4 | import Col from "react-bootstrap/Col";
5 | import ExerciseDropdown from "./ExerciseDropdown";
6 | import Form from "react-bootstrap/Form";
7 | import InputGroup from "react-bootstrap/InputGroup";
8 | import * as exerciseApi from "../../../api/exerciseApi";
9 |
10 |
11 | const ANY_BODY_PART = "Any Body Part";
12 | const ANY_EQUIPMENT = "Any Equipment";
13 |
14 |
15 | const FilterExercises = ({RenderExercises, handleSelect}) => {
16 | const [visibleExercises, setVisibleExercises] = useState(null);
17 | const [input, setInput] = useState("");
18 | const [selectedBodyPart, setSelectedBodyPart] = useState(ANY_BODY_PART);
19 | const [selectedEquipment, setSelectedEquipment] = useState(ANY_EQUIPMENT);
20 |
21 | const {data: exercises} = useQuery('exercise', exerciseApi.getExercises);
22 |
23 | const queryClient = useQueryClient();
24 | const bodyParts = queryClient.getQueryData("bodyParts");
25 | const equipment = queryClient.getQueryData("equipment");
26 |
27 | useEffect(() => {
28 | let filteredExercises = exercises?.filter(obj => obj.name.toLowerCase().includes(input.toLowerCase()))
29 | if (selectedBodyPart !== ANY_BODY_PART)
30 | filteredExercises = filteredExercises.filter(obj => obj.body_part === selectedBodyPart);
31 | if (selectedEquipment !== ANY_EQUIPMENT)
32 | filteredExercises = filteredExercises.filter(obj => obj.equipment === selectedEquipment);
33 | setVisibleExercises(filteredExercises);
34 | }, [exercises, input, selectedBodyPart, selectedEquipment]);
35 |
36 | return (
37 | <>
38 |
40 |
41 |
42 |
43 |
44 | setInput(e.target.value)}
49 | />
50 |
51 |
52 |
53 |
54 |
55 |
56 |
63 |
64 |
65 |
72 |
73 |
74 |
75 |
76 | >
77 | );
78 | };
79 |
80 | export default FilterExercises;
--------------------------------------------------------------------------------
/frontend/src/components/Profile/index.js:
--------------------------------------------------------------------------------
1 | import {useMutation, useQueryClient} from "react-query";
2 | import {useDispatch, useSelector} from "react-redux";
3 | import {toast} from "react-toastify";
4 | import ToggleButton from "react-bootstrap/ToggleButton";
5 | import ToggleButtonGroup from "react-bootstrap/ToggleButtonGroup";
6 | import Button from "react-bootstrap/Button";
7 | import * as profileApi from "../../api/profileApi";
8 | import * as authApi from "../../api/authApi";
9 | import {timestampToString} from "../../utils/helpers";
10 | import {updateWeightUnit} from "../../redux/slices/authSlice";
11 |
12 |
13 | const Profile = () => {
14 | const dispatch = useDispatch()
15 | const queryClient = useQueryClient();
16 | const weightUnit = useSelector((state) => state.auth.weightUnit)
17 | const profileData = queryClient.getQueryData("profile");
18 |
19 | const updateProfileMutation = useMutation(profileApi.updateProfile, {
20 | onSuccess: (data) => {
21 | const weightSystem = data["weight_system"];
22 | dispatch(updateWeightUnit(weightSystem));
23 | queryClient.invalidateQueries("profile");
24 | toast.success(`Weight unit: ${weightSystem} is saved!`);
25 | },
26 | onError: (error) => {
27 | console.error(error);
28 | toast.error("Could not update weight unit system!");
29 | },
30 | });
31 |
32 | const handleWeightUnitChange = (value) => {
33 | const payload = {"weight_system": value};
34 | updateProfileMutation.mutate(payload);
35 | };
36 |
37 | const logoutMutation = useMutation(authApi.blacklist, {
38 | onSettled: () => {
39 | dispatch({type: "CLEAR_SESSION"});
40 | },
41 | });
42 |
43 | return (
44 |
45 |
Profile
46 |
Email: {profileData?.["email"]}
47 |
Username: {profileData?.["username"]}
48 |
49 | Weight unit:
50 |
58 |
63 | Metric (kg)
64 |
65 |
70 | Imperial (lbs)
71 |
72 |
73 |
74 |
Account created: {timestampToString(profileData?.["created"])}
75 |
76 |
77 | logoutMutation.mutate()}>Log out
78 |
79 |
80 | );
81 | };
82 |
83 | export default Profile;
84 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # Gym Log
2 |
3 | Full Stack Django & React Web App with JWT authentication.
4 |
5 | This repo can be used as a starting point for developing a production-ready application using Django, React, and
6 | Postgres in a Dockerized environment with deployment to Heroku.
7 |
8 | ## Live web app
9 |
10 | To access the React application, go to [gym-log.herokuapp.com](https://gym-log.herokuapp.com/).
11 |
12 | To access the Django Swagger API endpoints, go
13 | to [gym-log.herokuapp.com/swagger/](https://gym-log.herokuapp.com/swagger/).
14 |
15 | > **_NOTE:_** The web application may take a few seconds to start up.
16 |
17 | ---
18 | 
19 |
20 | ## Motivation for creating the app
21 |
22 | I liked the mobile version of the [strong app](https://www.strong.app/) that allows logging workouts, viewing them, and
23 | getting a list of available exercises. So I implemented the basic functionality of
24 | the [strong app](https://www.strong.app/) in a CRUD Web Application with Django & React.
25 |
26 | ## Local deployment
27 |
28 | 1) Install docker: https://docs.docker.com/get-docker/
29 | 2) Clone github repo.
30 | 3) Run: `docker-compose up --build`.
31 |
32 | To access the fronted part of application open [http://localhost:3000](http://localhost:3000) in your browser.
33 |
34 | To view `Swagger API endpoints` open [http://localhost:8000/swagger/](http://localhost:8000/swagger/) in your browser.
35 |
36 | To view `Django admin site` open [http://localhost:8000/admin/](http://localhost:8000/admin/) in your browser.
37 |
38 | ## Production deployment
39 |
40 | 1) [Create Heroku Account](https://signup.heroku.com/dc)
41 | 2) [Download/Install/Setup Heroku CLI](https://devcenter.heroku.com/articles/heroku-cli#download-and-install)
42 | 3) After installation, log into Heroku CLI: `heroku login`.
43 | 4) Run: `heroku create ` to create the Heroku application.
44 | 5) Run: `heroku stack:set container` so Heroku knows this is a containerized application.
45 | 6) Run: `heroku addons:create heroku-postgresql:hobby-dev` which creates the postgres add-on for Heroku.
46 | 7) Set a `SECRET_KEY` in Heroku config vars settings.
47 | 8) Set the URL of your application, e.g. `https://.herokuapp.com`, into environment variable `HOST_NAME` in
48 | Heroku config vars settings.
49 | 9) Deploy your app by running: `git push heroku master`.
50 | 10) Go to `.herokuapp.com` to see the published web application.
51 |
52 | ### Dockerfile.prod
53 |
54 | Heroku uses multi-stage Docker build `Dockerfile.prod` file to build and run the application.
55 |
56 | If you want to build and run the production Dockerfile locally, use these commands:
57 |
58 | ```shell
59 | docker build --build-arg=PORT= -t gym-log:latest .
60 | docker run -it -p : gym-log:latest
61 | ```
62 |
63 | ---
64 |
65 | ### Main tools and libraries
66 |
67 | Backend:
68 |
69 | - `Django` as a web framework.
70 | - `Django REST framework` for building web APIs, serialization, and deserialization.
71 | - `JWT authentication` for securely transmitting information between frontend and backend applications.
72 | - `PostgreSQL` as a database in production.
73 |
74 | Frontend:
75 |
76 | - `React` for building user interface.
77 | - `React Bootstrap` for simplifying the creation and styling of React components.
78 | - `React Query` for managing server state, getting data from the backend, and updating it.
79 | - `Redux` for managing application state. And `Redux Persist` to store state between page reloads.
80 | - `Formik` and `Yup` for object schema validation for login and register pages.
81 |
82 | ### Ideas for improvement
83 |
84 | - Add logic for email confirmation on registration.
85 | - Add logic for password reset on the login page.
86 | - Add internationalization for several languages.
87 |
--------------------------------------------------------------------------------
/frontend/src/components/Workout/NewWorkout/AddWorkoutExerciseDetails.js:
--------------------------------------------------------------------------------
1 | import {useDispatch, useSelector} from "react-redux";
2 | import ListGroup from "react-bootstrap/ListGroup";
3 | import Form from "react-bootstrap/Form";
4 | import Button from "react-bootstrap/Button";
5 | import Row from "react-bootstrap/Row";
6 | import Col from "react-bootstrap/Col";
7 | import {addExerciseDetails, deleteExerciseDetails, updateExerciseDetails} from "../../../redux/slices/workoutSlice";
8 |
9 |
10 | const AddWorkoutExerciseDetails = ({exercise, exerciseIndex}) => {
11 | const dispatch = useDispatch();
12 | const weightUnit = useSelector((state) => state.auth.weightUnit)
13 |
14 | const handleInputChange = (index, event) => {
15 | if (event.target.validity.valid) {
16 | dispatch(updateExerciseDetails({
17 | exerciseIndex,
18 | index,
19 | name: event.target.name,
20 | value: parseInt(event.target.value)
21 | }))
22 | }
23 | }
24 |
25 | return (
26 |
92 | );
93 | };
94 |
95 | export default AddWorkoutExerciseDetails;
96 |
--------------------------------------------------------------------------------
/backend/core/models.py:
--------------------------------------------------------------------------------
1 | from django.db import models
2 | from django.db.models.signals import post_save
3 | from django.dispatch import receiver
4 | from django.utils.functional import classproperty
5 |
6 | from common.models import IndexedTimeStampedModel
7 | from user.models import User
8 |
9 |
10 | @receiver(post_save, sender=User)
11 | def create_profile(sender, instance, created, **kwargs):
12 | """
13 | Create OneToOne instance of Profile on User instance creation
14 | """
15 | if created:
16 | Profile.objects.create(user=instance)
17 |
18 |
19 | class Profile(models.Model):
20 | WEIGHT_SYSTEMS = (
21 | ('kg', 'kg'),
22 | ('lbs', 'lbs'),
23 | )
24 |
25 | user = models.OneToOneField(User, on_delete=models.CASCADE)
26 | weight_system = models.CharField(max_length=8, choices=WEIGHT_SYSTEMS, default=WEIGHT_SYSTEMS[0][0])
27 |
28 | def __str__(self):
29 | return f"Profile(user={self.user.username}, weight_system={self.weight_system})"
30 |
31 |
32 | class Exercise(models.Model):
33 | BODY_PARTS = (
34 | ('Forearms', 'Forearms'),
35 | ('Triceps', 'Triceps'),
36 | ('Biceps', 'Biceps'),
37 | ('Neck', 'Neck'),
38 | ('Shoulders', 'Shoulders'),
39 | ('Chest', 'Chest'),
40 | ('Back', 'Back'),
41 | ('Core', 'Core'),
42 | ('Upper Legs', 'Upper Legs'),
43 | ('Glutes', 'Glutes'),
44 | ('Calves', 'Calves'),
45 | ('Full Body', 'Full Body'),
46 | ('Other', 'Other'),
47 | )
48 | EQUIPMENT = (
49 | ('Barbell', 'Barbell'),
50 | ('Dumbbell', 'Dumbbell'),
51 | ('Machine', 'Machine'),
52 | ('Bodyweight', 'Bodyweight'),
53 | ('Bands', 'Bands'),
54 | ('Cardio', 'Cardio'),
55 | ('Other', 'Other'),
56 | )
57 |
58 | body_part = models.CharField(max_length=16, choices=BODY_PARTS)
59 | equipment = models.CharField(max_length=16, choices=EQUIPMENT)
60 | name = models.CharField(max_length=64)
61 | description = models.TextField(max_length=512, blank=True, null=True)
62 | user = models.ForeignKey(User, related_name="exercises", on_delete=models.CASCADE, blank=True, null=True)
63 |
64 | @classproperty
65 | def body_part_list(self):
66 | return [item[0] for item in self.BODY_PARTS]
67 |
68 | @classproperty
69 | def equipment_list(self):
70 | return [item[0] for item in self.EQUIPMENT]
71 |
72 | class Meta:
73 | unique_together = ('body_part', 'equipment', 'name')
74 |
75 | def __str__(self):
76 | return f"Exercise(body_part={self.body_part}, equipment={self.equipment}, name={self.name}, user={self.user}')"
77 |
78 |
79 | class Workout(IndexedTimeStampedModel):
80 | STATUSES = (
81 | ('Started', 'Started'),
82 | ('Finished', 'Finished'),
83 | ('Template', 'Template'),
84 | )
85 |
86 | user = models.ForeignKey(User, related_name="workouts", on_delete=models.CASCADE)
87 | status = models.CharField(max_length=8, choices=STATUSES, default=STATUSES[0][0])
88 |
89 | def __str__(self):
90 | return f"Workout(user={self.user.username}, created={self.created})"
91 |
92 |
93 | class WorkoutExercise(models.Model):
94 | workout = models.ForeignKey(Workout, related_name="workout_exercises", on_delete=models.CASCADE)
95 | exercise = models.ForeignKey(Exercise, on_delete=models.CASCADE)
96 |
97 | def __str__(self):
98 | return f"WorkoutExercise(workout={self.workout.user.username}, exercise={self.exercise.name})"
99 |
100 |
101 | class WorkoutExerciseDetail(models.Model):
102 | workout_exercise = models.ForeignKey(WorkoutExercise, related_name="workout_exercise_details",
103 | on_delete=models.CASCADE)
104 | sets = models.PositiveIntegerField()
105 | reps = models.PositiveIntegerField()
106 | weight = models.PositiveIntegerField()
107 |
108 | def __str__(self):
109 | return f"WorkoutExerciseDetail(workout_exercise={self.workout_exercise.exercise.name}, " \
110 | f"sets={self.sets}, reps={self.reps}, weight={self.weight})"
111 |
--------------------------------------------------------------------------------
/backend/core/migrations/0001_initial.py:
--------------------------------------------------------------------------------
1 | # Generated by Django 4.0.4 on 2022-06-24 06:44
2 |
3 | from django.conf import settings
4 | from django.db import migrations, models
5 | import django.db.models.deletion
6 | import django.utils.timezone
7 | import model_utils.fields
8 |
9 |
10 | class Migration(migrations.Migration):
11 |
12 | initial = True
13 |
14 | dependencies = [
15 | migrations.swappable_dependency(settings.AUTH_USER_MODEL),
16 | ]
17 |
18 | operations = [
19 | migrations.CreateModel(
20 | name='Exercise',
21 | fields=[
22 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
23 | ('body_part', models.CharField(choices=[('Forearms', 'Forearms'), ('Triceps', 'Triceps'), ('Biceps', 'Biceps'), ('Neck', 'Neck'), ('Shoulders', 'Shoulders'), ('Chest', 'Chest'), ('Back', 'Back'), ('Core', 'Core'), ('Upper Legs', 'Upper Legs'), ('Glutes', 'Glutes'), ('Calves', 'Calves'), ('Full Body', 'Full Body'), ('Other', 'Other')], max_length=16)),
24 | ('equipment', models.CharField(choices=[('Barbell', 'Barbell'), ('Dumbbell', 'Dumbbell'), ('Machine', 'Machine'), ('Bodyweight', 'Bodyweight'), ('Bands', 'Bands'), ('Cardio', 'Cardio'), ('Other', 'Other')], max_length=16)),
25 | ('name', models.CharField(max_length=64)),
26 | ('description', models.TextField(blank=True, max_length=512, null=True)),
27 | ('user', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='exercises', to=settings.AUTH_USER_MODEL)),
28 | ],
29 | options={
30 | 'unique_together': {('body_part', 'equipment', 'name')},
31 | },
32 | ),
33 | migrations.CreateModel(
34 | name='Workout',
35 | fields=[
36 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
37 | ('created', model_utils.fields.AutoCreatedField(db_index=True, default=django.utils.timezone.now, editable=False, verbose_name='created')),
38 | ('modified', model_utils.fields.AutoLastModifiedField(db_index=True, default=django.utils.timezone.now, editable=False, verbose_name='modified')),
39 | ('status', models.CharField(choices=[('Started', 'Started'), ('Finished', 'Finished'), ('Template', 'Template')], default='Started', max_length=8)),
40 | ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workouts', to=settings.AUTH_USER_MODEL)),
41 | ],
42 | options={
43 | 'abstract': False,
44 | },
45 | ),
46 | migrations.CreateModel(
47 | name='WorkoutExercise',
48 | fields=[
49 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
50 | ('exercise', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='core.exercise')),
51 | ('workout', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workout_exercises', to='core.workout')),
52 | ],
53 | ),
54 | migrations.CreateModel(
55 | name='WorkoutExerciseDetail',
56 | fields=[
57 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
58 | ('sets', models.PositiveIntegerField()),
59 | ('reps', models.PositiveIntegerField()),
60 | ('weight', models.PositiveIntegerField()),
61 | ('workout_exercise', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='workout_exercise_details', to='core.workoutexercise')),
62 | ],
63 | ),
64 | migrations.CreateModel(
65 | name='Profile',
66 | fields=[
67 | ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
68 | ('weight_system', models.CharField(choices=[('kg', 'kg'), ('lbs', 'lbs')], default='kg', max_length=8)),
69 | ('user', models.OneToOneField(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
70 | ],
71 | ),
72 | ]
73 |
--------------------------------------------------------------------------------
/frontend/src/components/Exercises/NewExerciseModal/index.js:
--------------------------------------------------------------------------------
1 | import {useState} from "react";
2 | import {useMutation, useQueryClient} from "react-query";
3 | import {toast} from "react-toastify";
4 | import Modal from "react-bootstrap/Modal";
5 | import Form from "react-bootstrap/Form";
6 | import Button from "react-bootstrap/Button";
7 | import Select from "./Select";
8 | import * as api from "../../../api/exerciseApi";
9 |
10 |
11 | const NewExerciseModal = (props) => {
12 | const [exerciseName, setExerciseName] = useState("");
13 | const [selectedBodyPart, setSelectedBodyPart] = useState("");
14 | const [selectedEquipment, setSelectedEquipment] = useState("");
15 | const [exerciseDescription, setExerciseDescription] = useState("");
16 |
17 | const queryClient = useQueryClient();
18 | const bodyParts = queryClient.getQueryData("bodyParts");
19 | const equipment = queryClient.getQueryData("equipment");
20 |
21 | const createExerciseMutation = useMutation(api.createExercise, {
22 | onError: (error) => {
23 | console.error(error);
24 | toast.error("Could not save exercise.");
25 | },
26 | onSuccess: (data) => {
27 | queryClient.invalidateQueries("exercise");
28 | toast.success(`Exercise: ${data["name"]} is saved!`);
29 | clear();
30 | },
31 | })
32 |
33 | const clear = () => {
34 | setExerciseName("");
35 | setSelectedBodyPart("");
36 | setSelectedEquipment("");
37 | setExerciseDescription("");
38 | props.onHide();
39 | };
40 |
41 | const save = (e) => {
42 | e.preventDefault();
43 | const payload = {
44 | "name": exerciseName.trim(),
45 | "body_part": selectedBodyPart ? selectedBodyPart : bodyParts?.[0],
46 | "equipment": selectedEquipment ? selectedEquipment : equipment?.[0],
47 | "description": exerciseDescription.trim()
48 | }
49 | createExerciseMutation.mutate(payload);
50 | };
51 |
52 | return (
53 |
54 |
103 |
104 | );
105 | };
106 |
107 | export default NewExerciseModal;
108 |
--------------------------------------------------------------------------------
/backend/backend/settings/base.py:
--------------------------------------------------------------------------------
1 | import os
2 | from datetime import timedelta
3 |
4 | from django.core.management.utils import get_random_secret_key
5 |
6 | SECRET_KEY = os.environ.get('SECRET_KEY', get_random_secret_key())
7 |
8 | BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "../../"))
9 |
10 | ADMINS = (("Admin", "vlad.moroshan@gmail.com"),)
11 |
12 | ALLOWED_HOSTS = ['*']
13 |
14 | # Application definition
15 | INSTALLED_APPS = [
16 | 'django.contrib.admin',
17 | 'django.contrib.auth',
18 | 'django.contrib.contenttypes',
19 | 'django.contrib.sessions',
20 | 'django.contrib.messages',
21 | 'django.contrib.staticfiles',
22 | 'rest_framework',
23 | 'rest_framework_simplejwt',
24 | 'rest_framework_simplejwt.token_blacklist',
25 | 'corsheaders',
26 | 'drf_yasg',
27 | 'backend',
28 | 'user',
29 | "core",
30 | ]
31 |
32 | AUTH_USER_MODEL = "user.User"
33 |
34 | MIDDLEWARE = [
35 | 'django.middleware.security.SecurityMiddleware',
36 | "whitenoise.middleware.WhiteNoiseMiddleware",
37 | 'django.contrib.sessions.middleware.SessionMiddleware',
38 | 'corsheaders.middleware.CorsMiddleware',
39 | 'django.middleware.common.CommonMiddleware',
40 | 'django.contrib.auth.middleware.AuthenticationMiddleware',
41 | 'django.contrib.messages.middleware.MessageMiddleware',
42 | 'django.middleware.clickjacking.XFrameOptionsMiddleware',
43 | ]
44 |
45 | ROOT_URLCONF = 'backend.urls'
46 |
47 | TEMPLATES = [
48 | {
49 | 'BACKEND': 'django.template.backends.django.DjangoTemplates',
50 | 'DIRS': [],
51 | 'APP_DIRS': True,
52 | 'OPTIONS': {
53 | 'context_processors': [
54 | 'django.template.context_processors.debug',
55 | 'django.template.context_processors.request',
56 | 'django.contrib.auth.context_processors.auth',
57 | 'django.contrib.messages.context_processors.messages',
58 | ],
59 | },
60 | },
61 | ]
62 |
63 | WSGI_APPLICATION = 'backend.wsgi.application'
64 |
65 | DATABASES = {
66 | 'default': {
67 | 'ENGINE': 'django.db.backends.sqlite3',
68 | 'NAME': os.path.join(BASE_DIR, "db.sqlite3")
69 | }
70 | }
71 |
72 | AUTH_PASSWORD_VALIDATORS = [
73 | {"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", },
74 | {"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator", },
75 | {"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator", },
76 | {"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator", },
77 | ]
78 |
79 | REST_FRAMEWORK = {
80 | 'DEFAULT_SCHEMA_CLASS': 'rest_framework.schemas.coreapi.AutoSchema',
81 | 'DEFAULT_AUTHENTICATION_CLASSES': [
82 | 'rest_framework_simplejwt.authentication.JWTAuthentication',
83 | ],
84 | # 'DEFAULT_RENDERER_CLASSES': ('rest_framework.renderers.JSONRenderer',)
85 | # 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.PageNumberPagination',
86 | # 'PAGE_SIZE': 10
87 | }
88 |
89 | SIMPLE_JWT = {
90 | 'ACCESS_TOKEN_LIFETIME': timedelta(hours=1),
91 | 'REFRESH_TOKEN_LIFETIME': timedelta(days=14),
92 | 'ROTATE_REFRESH_TOKENS': True,
93 | 'BLACKLIST_AFTER_ROTATION': True,
94 | 'ALGORITHM': 'HS256',
95 | 'SIGNING_KEY': SECRET_KEY,
96 | 'VERIFYING_KEY': None,
97 | 'AUTH_HEADER_TYPES': ('Bearer', 'JWT'),
98 | 'USER_ID_FIELD': 'id',
99 | 'USER_ID_CLAIM': 'user_id',
100 | 'AUTH_TOKEN_CLASSES': ('rest_framework_simplejwt.tokens.AccessToken',),
101 | 'TOKEN_TYPE_CLAIM': 'token_type',
102 | }
103 |
104 | SWAGGER_SETTINGS = {
105 | "api_version": '1.0',
106 | "relative_paths": True,
107 | 'VALIDATOR_URL': None,
108 | 'SECURITY_DEFINITIONS': {
109 | 'Bearer': {
110 | 'type': 'apiKey',
111 | 'name': 'Authorization',
112 | 'in': 'header'
113 | }
114 | }
115 | }
116 |
117 | LOGGING = {
118 | 'version': 1,
119 | 'disable_existing_loggers': False,
120 | 'handlers': {
121 | 'console': {
122 | 'class': 'logging.StreamHandler',
123 | 'formatter': 'standard'
124 | },
125 | 'file': {
126 | 'level': os.getenv('LOGGING_LEVEL', 'INFO'),
127 | 'class': 'logging.handlers.RotatingFileHandler',
128 | 'filename': 'info.log',
129 | 'maxBytes': 1024 * 1024 * 1, # 1 MB
130 | # 'backupCount': 1,
131 | 'formatter': 'standard',
132 | },
133 | },
134 | 'loggers': {
135 | 'django': {
136 | 'handlers': ['console', 'file'],
137 | 'level': os.getenv('LOGGING_LEVEL', 'INFO'),
138 | 'propagate': True,
139 | },
140 | 'django.utils.autoreload': {
141 | 'level': 'WARNING',
142 | }
143 | },
144 | 'formatters': {
145 | 'standard': {
146 | 'format': '{levelname} {asctime} - {message}',
147 | 'style': '{',
148 | },
149 | }
150 | }
151 |
152 | # Internationalization
153 |
154 | LANGUAGE_CODE = 'en-us'
155 |
156 | TIME_ZONE = 'UTC'
157 |
158 | USE_I18N = True
159 |
160 | USE_L10N = True
161 |
162 | USE_TZ = True
163 |
164 | STATIC_URL = '/static/'
165 |
166 | DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
167 |
168 | CORS_ALLOWED_ORIGINS = [
169 | "http://localhost:3000",
170 | "http://127.0.0.1:3000"
171 | ]
172 |
--------------------------------------------------------------------------------
/frontend/src/components/Login/index.js:
--------------------------------------------------------------------------------
1 | import {useState} from "react";
2 | import {Link, useLocation, useNavigate} from "react-router-dom";
3 | import {useDispatch} from "react-redux";
4 | import {useMutation} from "react-query";
5 | import {toast} from "react-toastify";
6 | import {Formik} from "formik";
7 | import * as yup from "yup";
8 | import Stack from "react-bootstrap/Stack";
9 | import Form from "react-bootstrap/Form";
10 | import Button from "react-bootstrap/Button";
11 | import InputGroup from "react-bootstrap/InputGroup";
12 | import * as api from "../../api/authApi";
13 | import {login} from "../../redux/slices/authSlice";
14 | import {pageRoutes} from "../../utils/routes";
15 |
16 |
17 | const schema = yup.object().shape({
18 | email: yup.string().label("Email").email().required(),
19 | password: yup.string().label("Password").required().min(8).max(32),
20 | });
21 |
22 |
23 | const Login = () => {
24 | const [showPassword, setShowPassword] = useState(false);
25 | const [formSubmitting, setFormSubmitting] = useState(false);
26 |
27 | const dispatch = useDispatch()
28 | const navigate = useNavigate();
29 |
30 | const location = useLocation();
31 | const redirectPath = location.state?.path || pageRoutes.workout;
32 |
33 | const loginMutation = useMutation(api.login, {
34 | onMutate: () => {
35 | setFormSubmitting(true);
36 | },
37 | onSuccess: (data) => {
38 | dispatch(login(data));
39 | navigate(redirectPath);
40 | },
41 | onError: () => {
42 | setFormSubmitting(false);
43 | toast.error("Could not log in!")
44 | },
45 | });
46 |
47 | return (
48 |
49 |
50 | {process.env.REACT_APP_NAME}
51 | Log In
52 |
53 | loginMutation.mutate(data)}
56 | initialValues={{
57 | email: "",
58 | password: "",
59 | }}
60 | >
61 | {({handleSubmit, handleChange, values, touched, errors}) => (
62 |
64 | Email address
65 |
74 |
75 | {errors.email}
76 |
77 |
78 |
79 |
80 | Password
81 |
82 |
91 |
92 | setShowPassword(!showPassword)}
94 | className={
95 | showPassword ? "fas fa-eye-slash" : "fas fa-eye"
96 | }
97 | />
98 |
99 |
100 | {errors.password}
101 |
102 |
103 |
104 |
105 |
106 |
107 | {formSubmitting ? "Loading" : "Log in"}
108 |
109 |
110 |
111 | )}
112 |
113 |
114 | Don't have an account? Register
115 |
116 |
117 |
118 | );
119 | };
120 |
121 | export default Login;
122 |
--------------------------------------------------------------------------------
/frontend/src/components/Register/index.js:
--------------------------------------------------------------------------------
1 | import {useState} from "react";
2 | import {Link, useNavigate} from "react-router-dom";
3 | import {useDispatch} from "react-redux";
4 | import {useMutation} from "react-query";
5 | import {Formik} from "formik";
6 | import {toast} from "react-toastify";
7 | import * as yup from "yup";
8 | import Stack from "react-bootstrap/Stack";
9 | import Form from "react-bootstrap/Form";
10 | import Button from "react-bootstrap/Button";
11 | import InputGroup from "react-bootstrap/InputGroup";
12 | import * as api from "../../api/authApi";
13 | import {login} from "../../redux/slices/authSlice";
14 | import {pageRoutes} from "../../utils/routes";
15 |
16 |
17 | const schema = yup.object().shape({
18 | email: yup.string().label("Email").email().required(),
19 | username: yup
20 | .string()
21 | .label("Username")
22 | .required()
23 | .min(3)
24 | .max(32)
25 | .matches(/[a-zA-Z]/, "Username can only contain Latin letters."),
26 |
27 | password: yup
28 | .string()
29 | .label("Password")
30 | .required()
31 | .min(8)
32 | .max(32)
33 | .matches(/^(?![_.])(?!.*[_.]{2})[a-zA-Z0-9._]+(? {
38 | const [showPassword, setShowPassword] = useState(false);
39 | const navigate = useNavigate();
40 | const dispatch = useDispatch()
41 |
42 | const registerMutation = useMutation(api.register, {
43 | onSuccess: (data) => {
44 | dispatch(login(data));
45 | navigate(pageRoutes.workout);
46 | },
47 | onError: () => {
48 | toast.error("Could not register!")
49 | },
50 | });
51 |
52 | return (
53 |
54 |
55 | {process.env.REACT_APP_NAME}
56 | Register
57 |
58 | registerMutation.mutate(values)}
61 | initialValues={{username: "", email: "", password: ""}}
62 | >
63 | {({handleSubmit, handleChange, values, touched, errors}) => (
64 |
67 | Email address
68 |
77 |
78 | {errors.email}
79 |
80 |
81 |
82 |
83 | Username
84 |
93 |
94 | {errors.username}
95 |
96 |
97 |
98 |
99 | Password
100 |
101 |
110 |
111 | setShowPassword(!showPassword)}
113 | className={showPassword ? "fas fa-eye-slash" : "fas fa-eye"}
114 | />
115 |
116 |
117 | {errors.password}
118 |
119 |
120 |
121 |
122 |
123 |
124 | Register
125 |
126 |
127 |
128 | )}
129 |
130 |
131 |
132 | Already a member? Log In
133 |
134 |
135 |
136 |
);
137 | };
138 |
139 | export default Register;
140 |
--------------------------------------------------------------------------------
/backend/core/fixtures/exercise.json:
--------------------------------------------------------------------------------
1 | [
2 | {
3 | "model": "core.exercise",
4 | "pk": 1,
5 | "fields": {
6 | "body_part": "Shoulders",
7 | "equipment": "Dumbbell",
8 | "name": "Arnold Press",
9 | "description": "",
10 | "user": null
11 | }
12 | },
13 | {
14 | "model": "core.exercise",
15 | "pk": 2,
16 | "fields": {
17 | "body_part": "Chest",
18 | "equipment": "Barbell",
19 | "name": "Bench Press",
20 | "description": "",
21 | "user": null
22 | }
23 | },
24 | {
25 | "model": "core.exercise",
26 | "pk": 3,
27 | "fields": {
28 | "body_part": "Chest",
29 | "equipment": "Dumbbell",
30 | "name": "Bench Press",
31 | "description": "",
32 | "user": null
33 | }
34 | },
35 | {
36 | "model": "core.exercise",
37 | "pk": 4,
38 | "fields": {
39 | "body_part": "Back",
40 | "equipment": "Barbell",
41 | "name": "Bent Over Row",
42 | "description": "",
43 | "user": null
44 | }
45 | },
46 | {
47 | "model": "core.exercise",
48 | "pk": 5,
49 | "fields": {
50 | "body_part": "Back",
51 | "equipment": "Dumbbell",
52 | "name": "Bent Over Row",
53 | "description": "",
54 | "user": null
55 | }
56 | },
57 | {
58 | "model": "core.exercise",
59 | "pk": 6,
60 | "fields": {
61 | "body_part": "Biceps",
62 | "equipment": "Barbell",
63 | "name": "Biceps Curl",
64 | "description": "",
65 | "user": null
66 | }
67 | },
68 | {
69 | "model": "core.exercise",
70 | "pk": 7,
71 | "fields": {
72 | "body_part": "Full Body",
73 | "equipment": "Bodyweight",
74 | "name": "Burpee",
75 | "description": "",
76 | "user": null
77 | }
78 | },
79 | {
80 | "model": "core.exercise",
81 | "pk": 8,
82 | "fields": {
83 | "body_part": "Core",
84 | "equipment": "Machine",
85 | "name": "Cable Crunch",
86 | "description": "",
87 | "user": null
88 | }
89 | },
90 | {
91 | "model": "core.exercise",
92 | "pk": 9,
93 | "fields": {
94 | "body_part": "Chest",
95 | "equipment": "Bodyweight",
96 | "name": "Dip",
97 | "description": "",
98 | "user": null
99 | }
100 | },
101 | {
102 | "model": "core.exercise",
103 | "pk": 10,
104 | "fields": {
105 | "body_part": "Chest",
106 | "equipment": "Machine",
107 | "name": "Chest Fly",
108 | "description": "",
109 | "user": null
110 | }
111 | },
112 | {
113 | "model": "core.exercise",
114 | "pk": 11,
115 | "fields": {
116 | "body_part": "Back",
117 | "equipment": "Bodyweight",
118 | "name": "Chin Up",
119 | "description": "",
120 | "user": null
121 | }
122 | },
123 | {
124 | "model": "core.exercise",
125 | "pk": 12,
126 | "fields": {
127 | "body_part": "Full Body",
128 | "equipment": "Barbell",
129 | "name": "Clean and Jerk",
130 | "description": "",
131 | "user": null
132 | }
133 | },
134 | {
135 | "model": "core.exercise",
136 | "pk": 13,
137 | "fields": {
138 | "body_part": "Back",
139 | "equipment": "Barbell",
140 | "name": "Deadlift",
141 | "description": "",
142 | "user": null
143 | }
144 | },
145 | {
146 | "model": "core.exercise",
147 | "pk": 14,
148 | "fields": {
149 | "body_part": "Shoulders",
150 | "equipment": "Dumbbell",
151 | "name": "Front Raise",
152 | "description": "",
153 | "user": null
154 | }
155 | },
156 | {
157 | "model": "core.exercise",
158 | "pk": 15,
159 | "fields": {
160 | "body_part": "Core",
161 | "equipment": "Barbell",
162 | "name": "Good Morning",
163 | "description": "",
164 | "user": null
165 | }
166 | },
167 | {
168 | "model": "core.exercise",
169 | "pk": 16,
170 | "fields": {
171 | "body_part": "Upper Legs",
172 | "equipment": "Machine",
173 | "name": "Hack Squat",
174 | "description": "",
175 | "user": null
176 | }
177 | },
178 | {
179 | "model": "core.exercise",
180 | "pk": 17,
181 | "fields": {
182 | "body_part": "Full Body",
183 | "equipment": "Barbell",
184 | "name": "Hang Clean",
185 | "description": "",
186 | "user": null
187 | }
188 | },
189 | {
190 | "model": "core.exercise",
191 | "pk": 18,
192 | "fields": {
193 | "body_part": "Full Body",
194 | "equipment": "Barbell",
195 | "name": "Hang Snatch",
196 | "description": "",
197 | "user": null
198 | }
199 | },
200 | {
201 | "model": "core.exercise",
202 | "pk": 19,
203 | "fields": {
204 | "body_part": "Core",
205 | "equipment": "Bodyweight",
206 | "name": "Hanging Leg Raise",
207 | "description": "",
208 | "user": null
209 | }
210 | },
211 | {
212 | "model": "core.exercise",
213 | "pk": 20,
214 | "fields": {
215 | "body_part": "Other",
216 | "equipment": "Other",
217 | "name": "Jump Rope",
218 | "description": "",
219 | "user": null
220 | }
221 | },
222 | {
223 | "model": "core.exercise",
224 | "pk": 21,
225 | "fields": {
226 | "body_part": "Shoulders",
227 | "equipment": "Dumbbell",
228 | "name": "Lateral Raise",
229 | "description": "",
230 | "user": null
231 | }
232 | },
233 | {
234 | "model": "core.exercise",
235 | "pk": 22,
236 | "fields": {
237 | "body_part": "Upper Legs",
238 | "equipment": "Machine",
239 | "name": "Leg Press",
240 | "description": "",
241 | "user": null
242 | }
243 | },
244 | {
245 | "model": "core.exercise",
246 | "pk": 23,
247 | "fields": {
248 | "body_part": "Upper Legs",
249 | "equipment": "Dumbbell",
250 | "name": "Lunge",
251 | "description": "",
252 | "user": null
253 | }
254 | },
255 | {
256 | "model": "core.exercise",
257 | "pk": 24,
258 | "fields": {
259 | "body_part": "Full Body",
260 | "equipment": "Bodyweight",
261 | "name": "Muscle Up",
262 | "description": "",
263 | "user": null
264 | }
265 | },
266 | {
267 | "model": "core.exercise",
268 | "pk": 25,
269 | "fields": {
270 | "body_part": "Shoulders",
271 | "equipment": "Barbell",
272 | "name": "Overhead Press",
273 | "description": "",
274 | "user": null
275 | }
276 | },
277 | {
278 | "model": "core.exercise",
279 | "pk": 26,
280 | "fields": {
281 | "body_part": "Core",
282 | "equipment": "Bodyweight",
283 | "name": "Plank",
284 | "description": "",
285 | "user": null
286 | }
287 | },
288 | {
289 | "model": "core.exercise",
290 | "pk": 27,
291 | "fields": {
292 | "body_part": "Back",
293 | "equipment": "Bodyweight",
294 | "name": "Pull up",
295 | "description": "",
296 | "user": null
297 | }
298 | },
299 | {
300 | "model": "core.exercise",
301 | "pk": 28,
302 | "fields": {
303 | "body_part": "Chest",
304 | "equipment": "Bodyweight",
305 | "name": "Push Up",
306 | "description": "",
307 | "user": null
308 | }
309 | },
310 | {
311 | "model": "core.exercise",
312 | "pk": 29,
313 | "fields": {
314 | "body_part": "Upper Legs",
315 | "equipment": "Barbell",
316 | "name": "Squat",
317 | "description": "",
318 | "user": null
319 | }
320 | },
321 | {
322 | "model": "core.exercise",
323 | "pk": 30,
324 | "fields": {
325 | "body_part": "Calves",
326 | "equipment": "Machine",
327 | "name": "Seated Calf Raise",
328 | "description": "",
329 | "user": null
330 | }
331 | },
332 | {
333 | "model": "core.exercise",
334 | "pk": 31,
335 | "fields": {
336 | "body_part": "Calves",
337 | "equipment": "Machine",
338 | "name": "Standing Calf Raise",
339 | "description": "",
340 | "user": null
341 | }
342 | },
343 | {
344 | "model": "core.exercise",
345 | "pk": 32,
346 | "fields": {
347 | "body_part": "Calves",
348 | "equipment": "Bodyweight",
349 | "name": "Donkey Calf Raise",
350 | "description": "",
351 | "user": null
352 | }
353 | },
354 | {
355 | "model": "core.exercise",
356 | "pk": 33,
357 | "fields": {
358 | "body_part": "Glutes",
359 | "equipment": "Bodyweight",
360 | "name": "Bridge",
361 | "description": "",
362 | "user": null
363 | }
364 | },
365 | {
366 | "model": "core.exercise",
367 | "pk": 34,
368 | "fields": {
369 | "body_part": "Forearms",
370 | "equipment": "Barbell",
371 | "name": "Seated Wrist Curl",
372 | "description": "",
373 | "user": null
374 | }
375 | },
376 | {
377 | "model": "core.exercise",
378 | "pk": 35,
379 | "fields": {
380 | "body_part": "Forearms",
381 | "equipment": "Other",
382 | "name": "Wrist Roller",
383 | "description": "",
384 | "user": null
385 | }
386 | },
387 | {
388 | "model": "core.exercise",
389 | "pk": 36,
390 | "fields": {
391 | "body_part": "Triceps",
392 | "equipment": "Bodyweight",
393 | "name": "Bench Dip",
394 | "description": "",
395 | "user": null
396 | }
397 | },
398 | {
399 | "model": "core.exercise",
400 | "pk": 37,
401 | "fields": {
402 | "body_part": "Triceps",
403 | "equipment": "Barbell",
404 | "name": "Close Grip Bench Press",
405 | "description": "",
406 | "user": null
407 | }
408 | },
409 | {
410 | "model": "core.exercise",
411 | "pk": 38,
412 | "fields": {
413 | "body_part": "Triceps",
414 | "equipment": "Barbell",
415 | "name": "Lying Triceps Extension",
416 | "description": "",
417 | "user": null
418 | }
419 | },
420 | {
421 | "model": "core.exercise",
422 | "pk": 39,
423 | "fields": {
424 | "body_part": "Triceps",
425 | "equipment": "Dumbbell",
426 | "name": "Seated Triceps Press",
427 | "description": "",
428 | "user": null
429 | }
430 | },
431 | {
432 | "model": "core.exercise",
433 | "pk": 40,
434 | "fields": {
435 | "body_part": "Shoulders",
436 | "equipment": "Bands",
437 | "name": "Band Back Fly",
438 | "description": "",
439 | "user": null
440 | }
441 | },
442 | {
443 | "model": "core.exercise",
444 | "pk": 41,
445 | "fields": {
446 | "body_part": "Upper Legs",
447 | "equipment": "Bands",
448 | "name": "Band Squat",
449 | "description": "",
450 | "user": null
451 | }
452 | },
453 | {
454 | "model": "core.exercise",
455 | "pk": 42,
456 | "fields": {
457 | "body_part": "Neck",
458 | "equipment": "Bodyweight",
459 | "name": "Neck Bridge",
460 | "description": "",
461 | "user": null
462 | }
463 | }
464 | ]
465 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | Apache License
2 | Version 2.0, January 2004
3 | http://www.apache.org/licenses/
4 | Copyright (c) 2022 Vladislav Moroshan
5 |
6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
7 |
8 | 1. Definitions.
9 |
10 | "License" shall mean the terms and conditions for use, reproduction,
11 | and distribution as defined by Sections 1 through 9 of this document.
12 |
13 | "Licensor" shall mean the copyright owner or entity authorized by
14 | the copyright owner that is granting the License.
15 |
16 | "Legal Entity" shall mean the union of the acting entity and all
17 | other entities that control, are controlled by, or are under common
18 | control with that entity. For the purposes of this definition,
19 | "control" means (i) the power, direct or indirect, to cause the
20 | direction or management of such entity, whether by contract or
21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the
22 | outstanding shares, or (iii) beneficial ownership of such entity.
23 |
24 | "You" (or "Your") shall mean an individual or Legal Entity
25 | exercising permissions granted by this License.
26 |
27 | "Source" form shall mean the preferred form for making modifications,
28 | including but not limited to software source code, documentation
29 | source, and configuration files.
30 |
31 | "Object" form shall mean any form resulting from mechanical
32 | transformation or translation of a Source form, including but
33 | not limited to compiled object code, generated documentation,
34 | and conversions to other media types.
35 |
36 | "Work" shall mean the work of authorship, whether in Source or
37 | Object form, made available under the License, as indicated by a
38 | copyright notice that is included in or attached to the work
39 | (an example is provided in the Appendix below).
40 |
41 | "Derivative Works" shall mean any work, whether in Source or Object
42 | form, that is based on (or derived from) the Work and for which the
43 | editorial revisions, annotations, elaborations, or other modifications
44 | represent, as a whole, an original work of authorship. For the purposes
45 | of this License, Derivative Works shall not include works that remain
46 | separable from, or merely link (or bind by name) to the interfaces of,
47 | the Work and Derivative Works thereof.
48 |
49 | "Contribution" shall mean any work of authorship, including
50 | the original version of the Work and any modifications or additions
51 | to that Work or Derivative Works thereof, that is intentionally
52 | submitted to Licensor for inclusion in the Work by the copyright owner
53 | or by an individual or Legal Entity authorized to submit on behalf of
54 | the copyright owner. For the purposes of this definition, "submitted"
55 | means any form of electronic, verbal, or written communication sent
56 | to the Licensor or its representatives, including but not limited to
57 | communication on electronic mailing lists, source code control systems,
58 | and issue tracking systems that are managed by, or on behalf of, the
59 | Licensor for the purpose of discussing and improving the Work, but
60 | excluding communication that is conspicuously marked or otherwise
61 | designated in writing by the copyright owner as "Not a Contribution."
62 |
63 | "Contributor" shall mean Licensor and any individual or Legal Entity
64 | on behalf of whom a Contribution has been received by Licensor and
65 | subsequently incorporated within the Work.
66 |
67 | 2. Grant of Copyright License. Subject to the terms and conditions of
68 | this License, each Contributor hereby grants to You a perpetual,
69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
70 | copyright license to reproduce, prepare Derivative Works of,
71 | publicly display, publicly perform, sublicense, and distribute the
72 | Work and such Derivative Works in Source or Object form.
73 |
74 | 3. Grant of Patent License. Subject to the terms and conditions of
75 | this License, each Contributor hereby grants to You a perpetual,
76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable
77 | (except as stated in this section) patent license to make, have made,
78 | use, offer to sell, sell, import, and otherwise transfer the Work,
79 | where such license applies only to those patent claims licensable
80 | by such Contributor that are necessarily infringed by their
81 | Contribution(s) alone or by combination of their Contribution(s)
82 | with the Work to which such Contribution(s) was submitted. If You
83 | institute patent litigation against any entity (including a
84 | cross-claim or counterclaim in a lawsuit) alleging that the Work
85 | or a Contribution incorporated within the Work constitutes direct
86 | or contributory patent infringement, then any patent licenses
87 | granted to You under this License for that Work shall terminate
88 | as of the date such litigation is filed.
89 |
90 | 4. Redistribution. You may reproduce and distribute copies of the
91 | Work or Derivative Works thereof in any medium, with or without
92 | modifications, and in Source or Object form, provided that You
93 | meet the following conditions:
94 |
95 | (a) You must give any other recipients of the Work or
96 | Derivative Works a copy of this License; and
97 |
98 | (b) You must cause any modified files to carry prominent notices
99 | stating that You changed the files; and
100 |
101 | (c) You must retain, in the Source form of any Derivative Works
102 | that You distribute, all copyright, patent, trademark, and
103 | attribution notices from the Source form of the Work,
104 | excluding those notices that do not pertain to any part of
105 | the Derivative Works; and
106 |
107 | (d) If the Work includes a "NOTICE" text file as part of its
108 | distribution, then any Derivative Works that You distribute must
109 | include a readable copy of the attribution notices contained
110 | within such NOTICE file, excluding those notices that do not
111 | pertain to any part of the Derivative Works, in at least one
112 | of the following places: within a NOTICE text file distributed
113 | as part of the Derivative Works; within the Source form or
114 | documentation, if provided along with the Derivative Works; or,
115 | within a display generated by the Derivative Works, if and
116 | wherever such third-party notices normally appear. The contents
117 | of the NOTICE file are for informational purposes only and
118 | do not modify the License. You may add Your own attribution
119 | notices within Derivative Works that You distribute, alongside
120 | or as an addendum to the NOTICE text from the Work, provided
121 | that such additional attribution notices cannot be construed
122 | as modifying the License.
123 |
124 | You may add Your own copyright statement to Your modifications and
125 | may provide additional or different license terms and conditions
126 | for use, reproduction, or distribution of Your modifications, or
127 | for any such Derivative Works as a whole, provided Your use,
128 | reproduction, and distribution of the Work otherwise complies with
129 | the conditions stated in this License.
130 |
131 | 5. Submission of Contributions. Unless You explicitly state otherwise,
132 | any Contribution intentionally submitted for inclusion in the Work
133 | by You to the Licensor shall be under the terms and conditions of
134 | this License, without any additional terms or conditions.
135 | Notwithstanding the above, nothing herein shall supersede or modify
136 | the terms of any separate license agreement you may have executed
137 | with Licensor regarding such Contributions.
138 |
139 | 6. Trademarks. This License does not grant permission to use the trade
140 | names, trademarks, service marks, or product names of the Licensor,
141 | except as required for reasonable and customary use in describing the
142 | origin of the Work and reproducing the content of the NOTICE file.
143 |
144 | 7. Disclaimer of Warranty. Unless required by applicable law or
145 | agreed to in writing, Licensor provides the Work (and each
146 | Contributor provides its Contributions) on an "AS IS" BASIS,
147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
148 | implied, including, without limitation, any warranties or conditions
149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
150 | PARTICULAR PURPOSE. You are solely responsible for determining the
151 | appropriateness of using or redistributing the Work and assume any
152 | risks associated with Your exercise of permissions under this License.
153 |
154 | 8. Limitation of Liability. In no event and under no legal theory,
155 | whether in tort (including negligence), contract, or otherwise,
156 | unless required by applicable law (such as deliberate and grossly
157 | negligent acts) or agreed to in writing, shall any Contributor be
158 | liable to You for damages, including any direct, indirect, special,
159 | incidental, or consequential damages of any character arising as a
160 | result of this License or out of the use or inability to use the
161 | Work (including but not limited to damages for loss of goodwill,
162 | work stoppage, computer failure or malfunction, or any and all
163 | other commercial damages or losses), even if such Contributor
164 | has been advised of the possibility of such damages.
165 |
166 | 9. Accepting Warranty or Additional Liability. While redistributing
167 | the Work or Derivative Works thereof, You may choose to offer,
168 | and charge a fee for, acceptance of support, warranty, indemnity,
169 | or other liability obligations and/or rights consistent with this
170 | License. However, in accepting such obligations, You may act only
171 | on Your own behalf and on Your sole responsibility, not on behalf
172 | of any other Contributor, and only if You agree to indemnify,
173 | defend, and hold each Contributor harmless for any liability
174 | incurred by, or claims asserted against, such Contributor by reason
175 | of your accepting any such warranty or additional liability.
176 |
177 | END OF TERMS AND CONDITIONS
178 |
179 | APPENDIX: How to apply the Apache License to your work.
180 |
181 | To apply the Apache License to your work, attach the following
182 | boilerplate notice, with the fields enclosed by brackets "[]"
183 | replaced with your own identifying information. (Don't include
184 | the brackets!) The text should be enclosed in the appropriate
185 | comment syntax for the file format. We also recommend that a
186 | file or class name and description of purpose be included on the
187 | same "printed page" as the copyright notice for easier
188 | identification within third-party archives.
189 |
190 | Copyright 2022 Vladislav Moroshan
191 |
192 | Licensed under the Apache License, Version 2.0 (the "License");
193 | you may not use this file except in compliance with the License.
194 | You may obtain a copy of the License at
195 |
196 | http://www.apache.org/licenses/LICENSE-2.0
197 |
198 | Unless required by applicable law or agreed to in writing, software
199 | distributed under the License is distributed on an "AS IS" BASIS,
200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
201 | See the License for the specific language governing permissions and
202 | limitations under the License.
203 |
--------------------------------------------------------------------------------