mirror of
https://github.com/workhardbekind/workout-challenge.git
synced 2026-07-04 01:13:32 -04:00
259 lines
No EOL
12 KiB
Python
259 lines
No EOL
12 KiB
Python
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() |