├── 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 | ![Django models relationships](https://i.ibb.co/3TV6d90/Models-Reletionshps.png) -------------------------------------------------------------------------------- /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 | 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) => )} 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 |
8 | 9 | {exercises?.map(exercise => 10 | 11 | 12 | 13 | 14 | 15 |
{exercise.name} ({exercise.equipment})
16 | {exercise.body_part} 17 |
{exercise.description}
18 |
19 |
20 |
21 |
22 | )} 23 |
24 |
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 | 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 | 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 | 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 | 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 | 50 | 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 | 48 | 49 | 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 |