mirror of
https://github.com/workhardbekind/workout-challenge.git
synced 2026-07-04 09:23:32 -04:00
first commit
This commit is contained in:
commit
e7f627801f
152 changed files with 35352 additions and 0 deletions
0
src-backend/workouts/__init__.py
Normal file
0
src-backend/workouts/__init__.py
Normal file
28
src-backend/workouts/admin.py
Normal file
28
src-backend/workouts/admin.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from .models import Workout
|
||||
from competition.models import Points
|
||||
|
||||
# Register your models here.
|
||||
class PointsInline(admin.TabularInline):
|
||||
"""Table of Competition categories"""
|
||||
|
||||
model = Points
|
||||
fk_name = "workout"
|
||||
can_delete = False
|
||||
extra = 0
|
||||
|
||||
@admin.register(Workout)
|
||||
class WorkoutAdmin(admin.ModelAdmin):
|
||||
"""Admin view of Workout"""
|
||||
|
||||
list_display = [
|
||||
"user",
|
||||
"sport_type",
|
||||
"start_datetime",
|
||||
"strava_id",
|
||||
]
|
||||
|
||||
inlines = [
|
||||
PointsInline,
|
||||
]
|
||||
6
src-backend/workouts/apps.py
Normal file
6
src-backend/workouts/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class WorkoutsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'workouts'
|
||||
13
src-backend/workouts/filters.py
Normal file
13
src-backend/workouts/filters.py
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
# filters.py
|
||||
import django_filters
|
||||
from .models import Workout
|
||||
|
||||
class WorkoutFilter(django_filters.FilterSet):
|
||||
my = django_filters.CharFilter(method='filter_my')
|
||||
|
||||
def filter_my(request, queryset, *args, **kwargs):
|
||||
return queryset.filter(id=request.request.user.id)
|
||||
|
||||
class Meta:
|
||||
model = Workout
|
||||
fields = {}
|
||||
259
src-backend/workouts/models.py
Normal file
259
src-backend/workouts/models.py
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
from django.db.models import Sum
|
||||
|
||||
from custom_user.models import CustomUser
|
||||
from competition.scorer import trigger_workout_change, trigger_workout_delete
|
||||
|
||||
# Create your models here.
|
||||
|
||||
SPORT_TYPE_GROUPS = [
|
||||
('GROUP_ANY', 'Group: All Sports'),
|
||||
('GROUP_RUNNING', 'Group: Running (Run / Trail / Treadmill)'),
|
||||
('GROUP_BIKING', 'Group: Biking/Cycling (Except E-Bike)'),
|
||||
('GROUP_WALKING', 'Group: Walking (Walk / Wheelchair / Elliptical / Stepper)'),
|
||||
('GROUP_RACKET', 'Group: Racket Sports (Tennis / Squash / Badminton / Pickleball / Racquetball / Table Tennis)'),
|
||||
('GROUP_SOCIAL', 'Group: Social Sports (Soccer / Golf)'),
|
||||
('GROUP_CLASSES_CARDIO', 'Group: Cardio Gym Classes (Crossfit / HIIT)'),
|
||||
('GROUP_CLASSES_MINDFUL', 'Group: Mindfulness Classes (Yoga / Pilates)'),
|
||||
('GROUP_WATER_ACTIVE', 'Group: Active Water Sports (Swim / Canoe / Kayak / Kitesurf / Rowing / Surfing / Windsurf)'),
|
||||
]
|
||||
|
||||
SPORT_TYPES = [
|
||||
('Steps', 'Total Daily Steps'),
|
||||
('Badminton', 'Badminton'),
|
||||
('Ride', 'Biking/Cycling'),
|
||||
('EBikeRide', 'Biking/Cycling (E-Bike)'),
|
||||
('GravelRide', 'Biking/Cycling (Gravel)'),
|
||||
('Handcycle', 'Biking/Cycling (Handcycle)'),
|
||||
('Velomobile', 'Biking/Cycling (Velomobile)'),
|
||||
('VirtualRide', 'Biking/Cycling (Virtual)'),
|
||||
('Canoeing', 'Canoe'),
|
||||
('Crossfit', 'Crossfit'),
|
||||
('Elliptical', 'Elliptical'),
|
||||
('Golf', 'Golf'),
|
||||
('HighIntensityIntervalTraining', 'High Intensity Interval Training (HIIT)'),
|
||||
('Hike', 'Hike'),
|
||||
('IceSkate', 'Ice Skate'),
|
||||
('InlineSkate', 'Inline Skate'),
|
||||
('Kayaking', 'Kayak'),
|
||||
('Kitesurf', 'Kitesurf'),
|
||||
('MountainBikeRide', 'Mountain-Biking/Cycling'),
|
||||
('EMountainBikeRide', 'Mountain-Biking/Cycling (E-Bike)'),
|
||||
('Pickleball', 'Pickleball'),
|
||||
('Pilates', 'Pilates'),
|
||||
('Racquetball', 'Racquetball'),
|
||||
('RockClimbing', 'Rock Climbing'),
|
||||
('Rowing', 'Rowing (Outdoor)'),
|
||||
('VirtualRow', 'Rowing (Virtual)'),
|
||||
('Run', 'Run'),
|
||||
('TrailRun', 'Run (Trail)'),
|
||||
('VirtualRun', 'Run (Treadmill / Vitual)'),
|
||||
('Sail', 'Sail'),
|
||||
('Skateboard', 'Skateboard'),
|
||||
('AlpineSki', 'Ski (Alpine)'),
|
||||
('BackcountrySki', 'Ski (Backcountry)'),
|
||||
('NordicSki', 'Ski (Nordic)'),
|
||||
('RollerSki', 'Ski (Roller/Inliner)'),
|
||||
('Snowboard', 'Snowboard'),
|
||||
('Soccer', 'Soccer / Football'),
|
||||
('Squash', 'Squash'),
|
||||
('StairStepper', 'Stair Stepper'),
|
||||
('StandUpPaddling', 'Stand-up Paddling'),
|
||||
('Surfing', 'Surf'),
|
||||
('Swim', 'Swim'),
|
||||
('TableTennis', 'Table Tennis'),
|
||||
('Tennis', 'Tennis'),
|
||||
('Walk', 'Walk'),
|
||||
('Snowshoe', 'Walk (Snowshoe)'),
|
||||
('WeightTraining', 'Weight Training'),
|
||||
('Wheelchair', 'Wheelchair'),
|
||||
('Windsurf', 'Windsurf'),
|
||||
('Yoga', 'Yoga'),
|
||||
('Workout', 'Other Workout'),
|
||||
]
|
||||
|
||||
# MET (Metabolic Equivalent) Source https://pacompendium.com/adult-compendium/
|
||||
SPORT_MET = {
|
||||
'Badminton': {1: 5.0, 2: 5.5, 3: 7.0, 4: 9.0},
|
||||
'Ride': {1: 4.3, 2: 7.0, 3: 9.0, 4: 12.0},
|
||||
'EBikeRide': {1: 4.0, 2: 6.0, 3: 6.8, 4: 7.0},
|
||||
'GravelRide': {1: 4.3, 2: 7.0, 3: 9.0, 4: 12.0},
|
||||
'Handcycle': {1: 4.3, 2: 7.0, 3: 9.0, 4: 12.0},
|
||||
'Velomobile': {1: 4.3, 2: 7.0, 3: 9.0, 4: 12.0},
|
||||
'VirtualRide': {1: 4.3, 2: 7.0, 3: 9.0, 4: 12.0},
|
||||
'Canoeing': {1: 2.8, 2: 3.5, 3: 5.8, 4: 12.0},
|
||||
'Crossfit': {1: 3.5, 2: 5.0, 3: 6.0, 4: 7.5},
|
||||
'Elliptical': {1: 3.0, 2: 5.0, 3: 7.0, 4: 9.0},
|
||||
'Golf': {1: 3.5, 2: 4.3, 3: 4.5, 4: 4.8},
|
||||
'HighIntensityIntervalTraining': {1: 5.0, 2: 7.0, 3: 9.0, 4: 11.0},
|
||||
'Hike': {1: 3.8, 2: 5.3, 3: 6.0, 4: 6.5},
|
||||
'IceSkate': {1: 7.5, 2: 9.8, 3: 12.3, 4: 15.5},
|
||||
'InlineSkate': {1: 7.5, 2: 9.8, 3: 12.3, 4: 15.5},
|
||||
'Kayaking': {1: 5.0, 2: 7.0, 3: 9.0, 4: 13.5},
|
||||
'Kitesurf': {1: 8.0, 2: 9.5, 3: 11.0, 4: 12.5},
|
||||
'MountainBikeRide': {1: 7.0, 2: 9.0, 3: 11.0, 4: 14.0},
|
||||
'EMountainBikeRide': {1: 6.0, 2: 8.0, 3: 8.8, 4: 9.0},
|
||||
'Pickleball': {1: 5.0, 2: 5.5, 3: 7.0, 4: 9.0},
|
||||
'Pilates': {1: 1.8, 2: 2.8, 3: 4.0, 4: 5.5},
|
||||
'Racquetball': {1: 5.5, 2: 7.0, 3: 8.5, 4: 10.0},
|
||||
'RockClimbing': {1: 5.8, 2: 7.3, 3: 8.8, 4: 10.5},
|
||||
'Rowing': {1: 5.0, 2: 7.5, 3: 11.0, 4: 14.0},
|
||||
'VirtualRow': {1: 5.0, 2: 7.5, 3: 11.0, 4: 14.0},
|
||||
'Run': {1: 7.8, 2: 10.5, 3: 11.8, 4: 13.0},
|
||||
'TrailRun': {1: 7.8, 2: 10.5, 3: 11.8, 4: 13.0},
|
||||
'VirtualRun': {1: 7.8, 2: 10.5, 3: 11.8, 4: 13.0},
|
||||
'Sail': {1: 3.0, 2: 3.3, 3: 4.5, 4: 9.3},
|
||||
'Skateboard': {1: 5.0, 2: 6.0, 3: 6.8, 4: 8.5},
|
||||
'AlpineSki': {1: 4.3, 2: 6.3, 3: 7.3, 4: 8.0},
|
||||
'BackcountrySki': {1: 6.8, 2: 8.5, 3: 9.5, 4: 11.3},
|
||||
'NordicSki': {1: 8.5, 2: 11.3, 3: 13.5, 4: 14.0},
|
||||
'RollerSki': {1: 6.8, 2: 8.5, 3: 9.5, 4: 11.3},
|
||||
'Snowboard': {1: 4.3, 2: 6.3, 3: 7.5, 4: 8.0},
|
||||
'Soccer': {1: 3.5, 2: 5.5, 3: 7.0, 4: 9.5},
|
||||
'Squash': {1: 5.0, 2: 7.3, 3: 9.0, 4: 12.0},
|
||||
'StairStepper': {1: 5.5, 2: 6.0, 3: 8.0, 4: 11.0},
|
||||
'StandUpPaddling': {1: 2.8, 2: 3.8, 3: 5.0, 4: 9.8},
|
||||
'Surfing': {1: 3.0, 2: 5.0, 3: 7.0, 4: 9.0},
|
||||
'Swim': {1: 5.8, 2: 8.0, 3: 9.8, 4: 10.5},
|
||||
'TableTennis': {1: 3.5, 2: 4.0, 3: 5.5, 4: 7.0},
|
||||
'Tennis': {1: 5.0, 2: 6.0, 3: 6.8, 4: 8.0},
|
||||
'Walk': {1: 3.0, 2: 3.8, 3: 4.8, 4: 5.5},
|
||||
'Snowshoe': {1: 5.0, 2: 5.8, 3: 6.8, 4: 7.5},
|
||||
'WeightTraining': {1: 3.0, 2: 3.5, 3: 5.0, 4: 6.0},
|
||||
'Wheelchair': {1: 3.3, 2: 3.8, 3: 5.3, 4: 6.3},
|
||||
'Windsurf': {1: 5.0, 2: 7.0, 3: 11.0, 4: 14.0},
|
||||
'Workout': {1: 2.5, 2: 4.0, 3: 6.0, 4: 8.0},
|
||||
'Yoga': {1: 2.0, 2: 3.0, 3: 4.0, 4: 6.0},
|
||||
}
|
||||
|
||||
INTENSITY_CATEGORIES = [
|
||||
(1, 'Easy (Could do another one later today)'),
|
||||
(2, 'Moderate (Done for today but tomorrow is a new day)'),
|
||||
(3, 'Hard (Will definitely feel this workout tomorrow)'),
|
||||
(4, "All Out (Can't do another one tomorrow)")
|
||||
]
|
||||
|
||||
|
||||
class Workout(models.Model):
|
||||
"""Workout - user logged workout"""
|
||||
|
||||
user = models.ForeignKey(CustomUser, on_delete=models.CASCADE, null=False, blank=False, primary_key=False)
|
||||
|
||||
sport_type = models.CharField(null=False, max_length=40, choices=SPORT_TYPES)
|
||||
start_datetime = models.DateTimeField(null=False)
|
||||
duration = models.DurationField(null=False)
|
||||
intensity_category = models.IntegerField(null=True, choices=INTENSITY_CATEGORIES)
|
||||
kcal = models.DecimalField(null=True, max_digits=7, decimal_places=2)
|
||||
distance = models.DecimalField(null=True, max_digits=7, decimal_places=2)
|
||||
steps = models.IntegerField(null=True)
|
||||
|
||||
strava_id = models.BigIntegerField(unique=True, null=True)
|
||||
strava_intensity_avg_watts = models.DecimalField(null=True, max_digits=7, decimal_places=2)
|
||||
|
||||
@property
|
||||
def duration_seconds(self):
|
||||
return self.duration.seconds
|
||||
|
||||
def __str__(self):
|
||||
"""str print-out of model entry"""
|
||||
return f"{self.start_datetime} - {self.sport_type} ({self.duration / (1_000 * 60)} min / {self.kcal} kcal)"
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
""" save initial field values to be able to detect changes """
|
||||
super().__init__(*args, **kwargs)
|
||||
self._original = self._dict()
|
||||
|
||||
#@property
|
||||
def _dict(self):
|
||||
""" dict of current fields and values - to detect changes """
|
||||
return {f.name: round(float(self.__dict__[f.attname]), 2) if isinstance(self.__dict__.get(f.attname), (Decimal, float)) else self.__dict__.get(f.attname) for f in self._meta.fields}
|
||||
|
||||
def get_changed_fields(self):
|
||||
""" check which fields have changed """
|
||||
current = self._dict()
|
||||
return {
|
||||
k: (v, current.get(k))
|
||||
for k, v in self._original.items()
|
||||
if v != current.get(k)
|
||||
}
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
""" trigger recalculation of points_capped if workout changes """
|
||||
is_create = self.pk is None
|
||||
scaling_kcal = float((1 if kwargs.get('user', None) is None else kwargs.get('user').scaling_kcal) if self.user is None else self.user.scaling_kcal)
|
||||
scaling_distance = float((1 if kwargs.get('user', None) is None else kwargs.get('user').scaling_distance) if self.user is None else self.user.scaling_distance)
|
||||
if self.sport_type == "Steps":
|
||||
self.intensity_category = 1
|
||||
|
||||
# Subtract the steps from walks and runs from the daily total steps to not double count
|
||||
recorded_walks = Workout.objects.filter(user=self.user, start_datetime__date=self.start_datetime, sport_type='Walk').aggregate(duration=Sum('duration'))['duration']
|
||||
recorded_runs = Workout.objects.filter(user=self.user, start_datetime__date=self.start_datetime, sport_type='Run').aggregate(duration=Sum('duration'))['duration']
|
||||
recorded_steps_walks = 0 if recorded_walks is None else 6_000 / (60 * 60) * recorded_walks.seconds
|
||||
recorded_steps_runs = 0 if recorded_runs is None else 10_000 / (60 * 60) * recorded_runs.seconds
|
||||
self.distance = 0.82 * scaling_distance * max(self.steps - recorded_steps_walks - recorded_steps_runs, 0) / 1000
|
||||
base_duration_seconds = self.distance * (1 / scaling_distance) / 5 * 60 * 60
|
||||
self.duration = datetime.timedelta(seconds=base_duration_seconds)
|
||||
self.kcal = SPORT_MET["Walk"][self.intensity_category] * 75 * (base_duration_seconds / (60 * 60)) * scaling_kcal # default human 75kg scaled up/down by scaler
|
||||
|
||||
# update start_datetime to server 23:59:00 in UTC
|
||||
server_time = datetime.datetime.combine(self.start_datetime.date(), datetime.time(23, 59, 0))
|
||||
self.start_datetime = timezone.make_aware(server_time).astimezone(datetime.timezone.utc)
|
||||
|
||||
if self.sport_type in ["Ride", "EBikeRide", "GravelRide", "Handcycle", "Velomobile", "VirtualRide", "MountainBikeRide", "EMountainBikeRide", "Run", "TrailRun", "VirtualRun", "Walk"]:
|
||||
# estimate distance using database MET values
|
||||
if self.distance is None or self.distance == "":
|
||||
self.distance = SPORT_MET.get(self.sport_type, SPORT_MET['Workout'])[self.intensity_category] * (self.duration.seconds / (60 * 60)) * scaling_distance # default human 1000m scaled up/down by scaler
|
||||
|
||||
# default intensity 2
|
||||
if self.intensity_category is None or self.intensity_category == "":
|
||||
self.intensity_category = 2
|
||||
|
||||
# estimate kcal using database MET values
|
||||
if self.kcal is None or self.kcal == "":
|
||||
self.kcal = SPORT_MET.get(self.sport_type, SPORT_MET['Workout'])[self.intensity_category] * 75 * (self.duration.seconds / (60 * 60)) * scaling_kcal # default human 75kg scaled up/down by scaler
|
||||
|
||||
super().save(*args, **kwargs)
|
||||
changed = self.get_changed_fields()
|
||||
trigger_workout_change(
|
||||
instance=self,
|
||||
new=is_create,
|
||||
changes=changed
|
||||
)
|
||||
self._original = self._dict() # reset
|
||||
|
||||
# if workout is run or walk and steps were recorded on the same day, update steps to avoid double counting
|
||||
if self.sport_type in ['Run', 'Walk']:
|
||||
if 'start_datetime' in changed:
|
||||
date_lst = datetime.datetime.fromisoformat(changed['start_datetime']) if type(changed['start_datetime']) is str else changed['start_datetime']
|
||||
date_lst = list(date_lst)
|
||||
else:
|
||||
date_lst = datetime.datetime.fromisoformat(self.start_datetime) if type(self.start_datetime) is str else self.start_datetime
|
||||
date_lst = [date_lst]
|
||||
recorded_steps = Workout.objects.filter(user=self.user, start_datetime__date__in=date_lst, sport_type='Steps')
|
||||
if len(recorded_steps) > 0:
|
||||
for steps in recorded_steps:
|
||||
setattr(steps, 'distance', None)
|
||||
setattr(steps, 'kcal', None)
|
||||
steps.save()
|
||||
|
||||
def delete(self, *args, **kwargs):
|
||||
""" trigger recalculation of points_capped if workout deleted """
|
||||
deleted_run_or_walk = self.sport_type in ['Run', 'Walk']
|
||||
trigger_workout_delete(
|
||||
instance=self
|
||||
)
|
||||
super().delete(*args, **kwargs)
|
||||
# if deleted workout was run or walk, update steps to give back counting
|
||||
if deleted_run_or_walk:
|
||||
recorded_steps = Workout.objects.filter(user=self.user, start_datetime__date=self.start_datetime, sport_type='Steps')
|
||||
if len(recorded_steps) > 0:
|
||||
for steps in recorded_steps:
|
||||
setattr(steps, 'distance', None)
|
||||
setattr(steps, 'kcal', None)
|
||||
setattr(steps, 'duration', datetime.timedelta(seconds=0))
|
||||
steps.save()
|
||||
14
src-backend/workouts/serializers.py
Normal file
14
src-backend/workouts/serializers.py
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
from rest_framework import serializers
|
||||
from .models import Workout
|
||||
|
||||
|
||||
class WorkoutSerializer(serializers.ModelSerializer):
|
||||
def validate(self, data):
|
||||
if data.get('sport_type') == 'Steps' and not data.get('steps'):
|
||||
raise serializers.ValidationError({'steps': 'Steps field is required when sport type is Steps'})
|
||||
return data
|
||||
|
||||
class Meta:
|
||||
model = Workout
|
||||
fields = ['id', 'sport_type', 'start_datetime', 'duration', 'duration_seconds', 'intensity_category', 'kcal', 'distance', 'steps', 'strava_id']
|
||||
read_only_fields = ['id', 'duration_seconds', 'strava_id'] #'duration_seconds',
|
||||
3
src-backend/workouts/tests.py
Normal file
3
src-backend/workouts/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
28
src-backend/workouts/views.py
Normal file
28
src-backend/workouts/views.py
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import time
|
||||
from django.db.models import Q
|
||||
from rest_framework import viewsets
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
|
||||
from custom_user.views import IsOwnerOrReadOnly
|
||||
from .models import Workout
|
||||
from competition.scorer import trigger_workout_change
|
||||
from .serializers import WorkoutSerializer
|
||||
from .filters import WorkoutFilter
|
||||
|
||||
|
||||
class WorkoutViewSet(viewsets.ModelViewSet):
|
||||
#queryset = Competition.objects.all()
|
||||
serializer_class = WorkoutSerializer
|
||||
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_class = WorkoutFilter
|
||||
|
||||
permission_classes = [IsOwnerOrReadOnly]
|
||||
|
||||
def get_queryset(self):
|
||||
# return all workouts from the user himself/herself
|
||||
#time.sleep(3) # throttle for testing
|
||||
return Workout.objects.select_related('user').filter(user__id=self.request.user.id).order_by('-start_datetime', '-duration', '-id') # | Q(points__goal__competition__user=self.request.user)).distinct().order_by('-start_datetime', '-duration', '-id')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
serializer.save(user=self.request.user)
|
||||
Loading…
Add table
Add a link
Reference in a new issue