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/__init__.py
Normal file
0
src-backend/__init__.py
Normal file
0
src-backend/competition/__init__.py
Normal file
0
src-backend/competition/__init__.py
Normal file
53
src-backend/competition/admin.py
Normal file
53
src-backend/competition/admin.py
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from .models import Competition, ActivityGoal, Team, Award
|
||||
from custom_user.models import CustomUser
|
||||
|
||||
# Register your models here.
|
||||
class ActivityGoalInline(admin.TabularInline):
|
||||
"""Table of Competition ActivityGoal"""
|
||||
|
||||
model = ActivityGoal
|
||||
fk_name = "competition"
|
||||
can_delete = False
|
||||
extra = 0
|
||||
|
||||
|
||||
class AwardsInline(admin.TabularInline):
|
||||
"""Table of Awards"""
|
||||
|
||||
model = Award
|
||||
fk_name = "competition"
|
||||
can_delete = False
|
||||
extra = 0
|
||||
|
||||
|
||||
class TeamInline(admin.TabularInline):
|
||||
"""Table of Competition teams"""
|
||||
|
||||
model = Team
|
||||
fk_name = "competition"
|
||||
can_delete = False
|
||||
extra = 0
|
||||
|
||||
|
||||
|
||||
@admin.register(Competition)
|
||||
class CompetitionAdmin(admin.ModelAdmin):
|
||||
"""Admin view of Competition - the highest level e.g. Football World Cup 2024"""
|
||||
|
||||
def has_delete_permission(self, request, obj=None):
|
||||
"""Block admins form deleting a Tournament"""
|
||||
return False
|
||||
|
||||
list_display = [
|
||||
"name",
|
||||
"start_date",
|
||||
"end_date",
|
||||
]
|
||||
inlines = [
|
||||
ActivityGoalInline,
|
||||
AwardsInline,
|
||||
TeamInline,
|
||||
]
|
||||
|
||||
6
src-backend/competition/apps.py
Normal file
6
src-backend/competition/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CompetitionConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'competition'
|
||||
231
src-backend/competition/models.py
Normal file
231
src-backend/competition/models.py
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
import time, re, random
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import models
|
||||
from django.db.models.signals import post_save
|
||||
from django.dispatch import receiver
|
||||
from django.core.validators import MinLengthValidator, RegexValidator
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from workouts.models import Workout, SPORT_TYPE_GROUPS, SPORT_TYPES
|
||||
from custom_user.models import CustomUser
|
||||
from .scorer import trigger_goal_change, trigger_competition_change
|
||||
|
||||
# Create your models here.
|
||||
COMPETITION_METRCIS = [
|
||||
('min', 'Time (Minutes)'),
|
||||
('num', 'Number of times (x)'),
|
||||
('kcal', 'Calories (Kcal)'),
|
||||
('km', 'Distance (Km)'),
|
||||
('kj', 'Effort (Kilojoules)'),
|
||||
]
|
||||
|
||||
POINT_REF_PERIODS = [
|
||||
('day', 'daily'),
|
||||
('week', 'weekly'),
|
||||
('month', 'monthly'),
|
||||
('year', 'yearly'),
|
||||
('competition', 'competition end'),
|
||||
]
|
||||
|
||||
|
||||
class Competition(models.Model):
|
||||
"""Competition users can compete in"""
|
||||
|
||||
owner = models.ForeignKey(CustomUser, on_delete=models.CASCADE, null=False, blank=False)
|
||||
|
||||
name = models.CharField(null=False, max_length=60)
|
||||
start_date = models.DateField(null=False)
|
||||
end_date = models.DateField(null=False)
|
||||
has_teams = models.BooleanField(default=False)
|
||||
organizer_assigns_teams = models.BooleanField(default=False)
|
||||
|
||||
join_code = models.CharField(
|
||||
blank=False,
|
||||
null=False,
|
||||
max_length=20,
|
||||
validators=[
|
||||
MinLengthValidator(10),
|
||||
RegexValidator(r'^[a-zA-Z0-9]+$', message="Only letters and numbers allowed"),
|
||||
],
|
||||
unique=True,
|
||||
)
|
||||
|
||||
@property
|
||||
def start_date_fmt(self):
|
||||
return self.start_date.strftime("%a, %b %-d")
|
||||
|
||||
@property
|
||||
def start_date_epoch(self):
|
||||
return int(time.mktime(self.start_date.timetuple()))
|
||||
|
||||
@property
|
||||
def end_date_fmt(self):
|
||||
return self.end_date.strftime("%a, %b %-d")
|
||||
|
||||
@property
|
||||
def end_date_epoch(self):
|
||||
return int(time.mktime(self.end_date.timetuple()))
|
||||
|
||||
def __str__(self):
|
||||
"""str print-out of model entry"""
|
||||
return f"{self.name} ({self.start_date} - {self.end_date})"
|
||||
|
||||
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 competition changes """
|
||||
is_create = self.pk is None
|
||||
if self.join_code == '':
|
||||
self.join_code = re.sub(r'[^a-zA-Z0-9]', '', self.name)[:8] + str(self.owner.pk).zfill(3) + str(random.randint(10_000, 99_999))
|
||||
self.join_code = self.join_code.upper()
|
||||
super().save(*args, **kwargs)
|
||||
changed = self.get_changed_fields()
|
||||
trigger_competition_change(
|
||||
instance=self,
|
||||
new=is_create,
|
||||
changes=changed
|
||||
)
|
||||
self._original = self._dict() # reset
|
||||
|
||||
# add default activity goals if new competition
|
||||
if is_create:
|
||||
ActivityGoal(name ='Exercise', competition = self, metric = 'min', goal = 150, period = 'week', max_per_day = 60, max_per_week = 240).save() # WHO recommends at least 75-150 min vigorous activity per week (capped at 4h)
|
||||
ActivityGoal(name='Move', competition=self, metric='kcal', goal=1_800, period='week', max_per_day=1_000, max_per_week=3_000).save() # 12kcal per minute
|
||||
self.owner.my_competitions.add(self) # add owner as participant
|
||||
|
||||
|
||||
class Team(models.Model):
|
||||
"""Competition teams users can join"""
|
||||
|
||||
competition = models.ForeignKey(Competition, on_delete=models.CASCADE, null=False, blank=False)
|
||||
|
||||
name = models.CharField(null=False, max_length=60)
|
||||
|
||||
def clean(self):
|
||||
super().clean()
|
||||
#self.validate_members()
|
||||
|
||||
|
||||
# ToDo: Check if user is participant in competition he/she whats to join the team of
|
||||
#def validate_members(self):
|
||||
# if self.competition.pk not in [member.competition.pk for member in self.members]:
|
||||
# raise ValidationError({'member': 'User must have joined competition to be a team member of team.'})
|
||||
|
||||
def __str__(self):
|
||||
"""str print-out of model entry"""
|
||||
return f"{self.competition} - Team: {self.name}"
|
||||
|
||||
|
||||
class ActivityGoal(models.Model):
|
||||
"""Activity goals in Competition - user will earn points for each rule/category"""
|
||||
|
||||
competition = models.ForeignKey(Competition, on_delete=models.CASCADE, null=False, blank=False)
|
||||
|
||||
name = models.CharField(null=False, max_length=60)
|
||||
|
||||
metric = models.CharField(null=False, max_length=4, choices=COMPETITION_METRCIS)
|
||||
goal = models.DecimalField(null=False, max_digits=10, decimal_places=2)
|
||||
period = models.CharField(null=False, max_length=12, default='day', choices=POINT_REF_PERIODS)
|
||||
|
||||
count_steps_as_walks = models.BooleanField(default=True)
|
||||
|
||||
min_per_workout = models.DecimalField(null=True, blank=True, max_digits=10, decimal_places=2)
|
||||
max_per_workout = models.DecimalField(null=True, blank=True, max_digits=10, decimal_places=2)
|
||||
min_per_day = models.DecimalField(null=True, blank=True, max_digits=10, decimal_places=2)
|
||||
max_per_day = models.DecimalField(null=True, blank=True, max_digits=10, decimal_places=2)
|
||||
min_per_week = models.DecimalField(null=True, blank=True, max_digits=10, decimal_places=2)
|
||||
max_per_week = models.DecimalField(null=True, blank=True, max_digits=10, decimal_places=2)
|
||||
|
||||
def __str__(self):
|
||||
"""str print-out of model entry"""
|
||||
return f"{self.competition}: {self.name} ({self.goal} {self.metric})"
|
||||
|
||||
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 goal changes """
|
||||
is_create = self.pk is None
|
||||
super().save(*args, **kwargs)
|
||||
changed = self.get_changed_fields()
|
||||
trigger_goal_change(
|
||||
instance=self,
|
||||
new=is_create,
|
||||
changes=changed
|
||||
)
|
||||
self._original = self._dict() # reset
|
||||
|
||||
|
||||
|
||||
|
||||
class Award(models.Model):
|
||||
"""Awards in Competition - user can earn points for comppleting awards"""
|
||||
|
||||
competition = models.ForeignKey(Competition, on_delete=models.CASCADE, null=False, blank=False)
|
||||
|
||||
name = models.CharField(null=False, max_length=60)
|
||||
sport = models.CharField(null=False, default='GROUP_ANY', max_length=40, choices=SPORT_TYPE_GROUPS + SPORT_TYPES)
|
||||
threshold = models.DecimalField(null=False, max_digits=10, decimal_places=2)
|
||||
period = models.CharField(null=False, max_length=12, default='day', choices=POINT_REF_PERIODS)
|
||||
reward_points = models.IntegerField(null=False)
|
||||
|
||||
def __str__(self):
|
||||
"""str print-out of model entry"""
|
||||
return f"{self.competition}: {self.name} ({self.reward_points} {self.period})"
|
||||
|
||||
|
||||
|
||||
class Points(models.Model):
|
||||
"""Points earned for User's Workout for this category or award"""
|
||||
|
||||
goal = models.ForeignKey(ActivityGoal, on_delete=models.CASCADE, null=True, blank=True)
|
||||
award = models.ForeignKey(Award, on_delete=models.CASCADE, null=True, blank=True)
|
||||
workout = models.ForeignKey(Workout, on_delete=models.CASCADE, null=False, blank=False)
|
||||
|
||||
points_raw = models.DecimalField(null=False, max_digits=10, decimal_places=2)
|
||||
points_capped = models.DecimalField(null=True, max_digits=10, decimal_places=2)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Points"
|
||||
verbose_name_plural = "Points"
|
||||
constraints = [
|
||||
models.UniqueConstraint(fields=['goal', 'award', 'workout'], name='unique_goal_award_workout')
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
"""str print-out of model entry"""
|
||||
return f"{self.award if self.goal is None else self.goal} - {self.points_raw}"
|
||||
204
src-backend/competition/scorer.py
Normal file
204
src-backend/competition/scorer.py
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
import datetime
|
||||
from django.apps import apps
|
||||
|
||||
from custom_user.point_recalc import trigger_recalc_points
|
||||
|
||||
|
||||
def _calculate_points_raw(goal, workout, user):
|
||||
goal_metric = goal.metric
|
||||
goal_target = float(goal.goal)
|
||||
|
||||
if goal_metric == 'min':
|
||||
if workout.duration is None or workout.duration == '':
|
||||
points = 0
|
||||
else:
|
||||
points = float(workout.duration.total_seconds()) / 60 / goal_target
|
||||
elif goal_metric == 'num':
|
||||
points = 1 / goal_target
|
||||
elif goal_metric == 'kcal':
|
||||
if workout.kcal is None or workout.kcal == '':
|
||||
points = 0
|
||||
else:
|
||||
points = float(workout.kcal) / (goal_target * float(user.scaling_kcal))
|
||||
elif goal_metric == 'km':
|
||||
if workout.distance is None or workout.distance == '':
|
||||
points = 0
|
||||
else:
|
||||
points = float(workout.distance) / (goal_target * float(user.scaling_distance))
|
||||
elif goal_metric == 'kj':
|
||||
if workout.kcal is None or workout.kcal == '':
|
||||
points = 0
|
||||
else:
|
||||
points = float(workout.kcal) * 4.18 / (goal_target * float(user.scaling_kcal))
|
||||
return points * 100
|
||||
|
||||
|
||||
def trigger_workout_delete(instance):
|
||||
RecalcRequest = apps.get_model('custom_user', 'RecalcRequest')
|
||||
for points in instance.points_set.all():
|
||||
RecalcRequest(user=instance.user, goal=points.goal, start_datetime=instance.start_datetime).save()
|
||||
print(f"Workout ({instance.pk}) deletion triggered point cap recalc - after {instance.start_datetime.isoformat()}")
|
||||
|
||||
trigger_recalc_points()
|
||||
|
||||
|
||||
def trigger_workout_change(instance, new, changes):
|
||||
|
||||
RecalcRequest = apps.get_model('custom_user', 'RecalcRequest')
|
||||
|
||||
if new:
|
||||
# newly created workout - add point entries
|
||||
Points = apps.get_model('competition', 'Points')
|
||||
start_datetime = datetime.datetime.strptime(instance.start_datetime, '%Y-%m-%dT%H:%M:%SZ') if type(instance.start_datetime) is str else instance.start_datetime
|
||||
for competition in instance.user.my_competitions.filter(start_date__lte=start_datetime, end_date__gte=start_datetime):
|
||||
for goal in competition.activitygoal_set.all():
|
||||
if goal.count_steps_as_walks or instance.sport_type != 'Steps':
|
||||
points = _calculate_points_raw(goal=goal, workout=instance, user=instance.user)
|
||||
Points(goal=goal, workout=instance, points_raw=points, points_capped=points).save()
|
||||
RecalcRequest(user=instance.user, goal=goal, start_datetime=start_datetime).save()
|
||||
else:
|
||||
# updated existing workout
|
||||
# check if relevant field was changed
|
||||
metric_change_lst = []
|
||||
if 'start_datetime' in changes:
|
||||
metric_change_lst.extend(['min', 'num', 'kcal', 'km', 'kj'])
|
||||
if 'duration' in changes:
|
||||
metric_change_lst.extend(['min'])
|
||||
if 'kcal' in changes:
|
||||
metric_change_lst.extend(['kcal', 'kj'])
|
||||
if 'distance' in changes:
|
||||
metric_change_lst.extend(['km'])
|
||||
|
||||
recalc_start_datetime = changes.get('start_datetime', [instance.start_datetime])[0]
|
||||
for recalc_points, recalc_goal in [(i, i.goal) for i in instance.points_set.all() if i.goal.metric in metric_change_lst]:
|
||||
points = _calculate_points_raw(goal=recalc_goal, workout=instance, user=instance.user)
|
||||
setattr(recalc_points, 'points_raw', points)
|
||||
setattr(recalc_points, 'points_capped', points)
|
||||
recalc_points.save()
|
||||
RecalcRequest(user=instance.user, goal=recalc_goal, start_datetime=recalc_start_datetime).save()
|
||||
|
||||
print(f"Workout ({instance.pk}) update triggered point cap recalc - {'NEW ENTRY' if new else 'EXISTING CHANGED'}" + ("" if new else f" - {changes}"))
|
||||
|
||||
trigger_recalc_points()
|
||||
|
||||
|
||||
def trigger_goal_change(instance, new, changes):
|
||||
RecalcRequest = apps.get_model('custom_user', 'RecalcRequest')
|
||||
Points = apps.get_model('competition', 'Points')
|
||||
Workout = apps.get_model('workouts', 'Workout')
|
||||
if new:
|
||||
# newly created goal - add point entries
|
||||
workout_lst = Workout.objects.filter(start_datetime__gte=instance.competition.start_date, start_datetime__lte=instance.competition.end_date + datetime.timedelta(days=1), user__in=instance.competition.user.all())
|
||||
if instance.count_steps_as_walks is False:
|
||||
workout_lst = workout_lst.exclude(sport_type='Steps')
|
||||
for workout in workout_lst:
|
||||
points = _calculate_points_raw(goal=instance, workout=workout, user=workout.user)
|
||||
Points(goal=instance, workout=workout, points_raw=points, points_capped=points).save()
|
||||
RecalcRequest(user=workout.user, goal=instance, start_datetime=workout.start_datetime).save()
|
||||
else:
|
||||
# updated existing workout
|
||||
# check if relevant field was changed
|
||||
_ = changes.pop('name', None)
|
||||
if len(changes) > 0:
|
||||
if 'count_steps_as_walks' in changes:
|
||||
# add steps
|
||||
if changes['count_steps_as_walks'][1]:
|
||||
for workout in Workout.objects.filter(start_datetime__gte=instance.competition.start_date, start_datetime__lte=instance.competition.end_date + datetime.timedelta(days=1), user__in=instance.competition.user.all(), sport_type='Steps'):
|
||||
points = _calculate_points_raw(goal=instance, workout=workout, user=workout.user)
|
||||
Points(goal=instance, workout=workout, points_raw=points, points_capped=points).save()
|
||||
# remove steps
|
||||
else:
|
||||
for point in instance.points_set.filter(workout__sport_type='Steps'):
|
||||
point.delete()
|
||||
for user in instance.competition.user.all():
|
||||
RecalcRequest(user=user, goal=instance, start_datetime=instance.competition.start_date).save()
|
||||
|
||||
trigger_recalc_points()
|
||||
|
||||
|
||||
def trigger_competition_change(instance, new, changes):
|
||||
Points = apps.get_model('competition', 'Points')
|
||||
RecalcRequest = apps.get_model('custom_user', 'RecalcRequest')
|
||||
Workout = apps.get_model('workouts', 'Workout')
|
||||
|
||||
# newly created competitions are ignored as only relevant if new goals are created
|
||||
# only catching changes of the start_date and end_date below
|
||||
|
||||
if 'start_date' in changes:
|
||||
if changes['start_date'][1] < changes['start_date'][0]:
|
||||
# add point entries before changes['start_date'][0] till [1]
|
||||
for goal in instance.activitygoal_set.all():
|
||||
for workout in Workout.objects.filter(start_datetime__gte=changes['start_date'][1], start_datetime__lte=changes['start_date'][0], user__in=instance.user.all()):
|
||||
points = _calculate_points_raw(goal=goal, workout=workout, user=workout.user)
|
||||
Points(goal=goal, workout=workout, points_raw=points, points_capped=points).save()
|
||||
RecalcRequest(user=workout.user, goal=goal, start_datetime=workout.start_datetime).save()
|
||||
print(f"Competition ({instance.pk}) start_date was extended from {changes['start_date'][0]} to {changes['start_date'][1]} triggering point cap recalc")
|
||||
else:
|
||||
# remove point entries before changes['start_date'][1]
|
||||
points_to_delete = Points.objects.filter(goal__competition=instance, workout__start_datetime__lt=changes['start_date'][1])
|
||||
CustomUser = apps.get_model('custom_user', 'CustomUser')
|
||||
ActivityGoal = apps.get_model('competition', 'ActivityGoal')
|
||||
for user in [CustomUser.objects.get(pk=i) for i in set(points_to_delete.values_list('workout__user', flat=True))]:
|
||||
for goal in [ActivityGoal.objects.get(pk=i) for i in set(points_to_delete.values_list('goal', flat=True))]:
|
||||
RecalcRequest(user=user, goal=goal, start_datetime=changes['start_date'][1]).save()
|
||||
points_to_delete.delete()
|
||||
print(f"Competition ({instance.pk}) start_date was shortened from {changes['start_date'][0]} to {changes['start_date'][1]} triggering point cap recalc")
|
||||
|
||||
trigger_recalc_points
|
||||
|
||||
if 'end_date' in changes:
|
||||
if changes['end_date'][1] > changes['end_date'][0]:
|
||||
# add point entries after changes['end_date'][0] till [1]
|
||||
for goal in instance.activitygoal_set.all():
|
||||
for workout in Workout.objects.filter(start_datetime__gte=changes['end_date'][0] + datetime.timedelta(days=1), start_datetime__lte=changes['end_date'][1] + datetime.timedelta(days=1), user__in=instance.user.all()):
|
||||
points = _calculate_points_raw(goal=goal, workout=workout, user=workout.user)
|
||||
Points(goal=goal, workout=workout, points_raw=points, points_capped=points).save()
|
||||
RecalcRequest(user=workout.user, goal=goal, start_datetime=workout.start_datetime).save()
|
||||
print(f"Competition ({instance.pk}) end_date was extended from {changes['end_date'][0]} to {changes['end_date'][1]} triggering point cap recalc")
|
||||
else:
|
||||
# remove point entries after changes['end_date'][1]
|
||||
Points.objects.filter(goal__competition=instance, workout__start_datetime__gt=changes['end_date'][1]).delete()
|
||||
print(f"Competition ({instance.pk}) end_date was shortened from {changes['end_date'][0]} to {changes['end_date'][1]} NOT triggering point cap recalc")
|
||||
|
||||
trigger_recalc_points()
|
||||
|
||||
|
||||
def trigger_user_change(instance, new, changes):
|
||||
Points = apps.get_model('competition', 'Points')
|
||||
RecalcRequest = apps.get_model('custom_user', 'RecalcRequest')
|
||||
|
||||
# check if user leaves or joins a competition
|
||||
if 'my_competitions' in changes:
|
||||
# instance user obj / changes = pk_set comp id to add/remove
|
||||
if changes['my_competitions'][0] is None:
|
||||
# add/join competition
|
||||
Workout = apps.get_model('workouts', 'Workout')
|
||||
Competition = apps.get_model('competition', 'Competition')
|
||||
for competition in Competition.objects.filter(pk__in=changes['my_competitions'][1]):
|
||||
workout_lst = Workout.objects.filter(user=instance, start_datetime__gte=competition.start_date, start_datetime__lte=competition.end_date + datetime.timedelta(days=1))
|
||||
for goal in competition.activitygoal_set.all():
|
||||
for workout in workout_lst:
|
||||
points = _calculate_points_raw(goal=goal, workout=workout, user=instance)
|
||||
Points(goal=goal, workout=workout, points_raw=points, points_capped=points).save()
|
||||
RecalcRequest(user=instance, goal=goal, start_datetime=competition.start_date).save()
|
||||
print(f"User ({instance.pk}) join competitions {changes['my_competitions'][1]} triggering point cap recalc")
|
||||
else:
|
||||
# remove/leave competition
|
||||
Points.objects.filter(goal__competition__in=changes['my_competitions'][0], workout__user=instance).delete()
|
||||
print(f"User ({instance.pk}) left competitions {changes['my_competitions'][0]} NOT triggering point cap recalc")
|
||||
|
||||
trigger_recalc_points()
|
||||
|
||||
# check if equalizing / scaling factors were changed
|
||||
if 'scaling_distance' in changes or 'scaling_kcal' in changes:
|
||||
goal_metrics = (['km'] if 'scaling_distance' in changes else []) + (['kcal', 'kj'] if 'scaling_kcal' in changes else [])
|
||||
recalc_points = Points.objects.filter(goal__metric__in=goal_metrics, workout__user=instance)
|
||||
|
||||
for recalc_point in recalc_points:
|
||||
points = _calculate_points_raw(goal=recalc_point.goal, workout=recalc_point.workout, user=instance)
|
||||
setattr(recalc_point, 'points_raw', points)
|
||||
setattr(recalc_point, 'points_capped', points)
|
||||
recalc_point.save()
|
||||
RecalcRequest(user=instance, goal=recalc_point.goal, start_datetime=recalc_point.workout.start_datetime).save()
|
||||
|
||||
print(f"User ({instance.pk}) scaling factors {goal_metrics} changed triggering point cap recalc")
|
||||
57
src-backend/competition/serializers.py
Normal file
57
src-backend/competition/serializers.py
Normal file
|
|
@ -0,0 +1,57 @@
|
|||
from rest_framework import serializers
|
||||
from custom_user.models import CustomUser
|
||||
from .models import Competition, ActivityGoal, Team, Points
|
||||
|
||||
|
||||
class CompetitionSerializer(serializers.ModelSerializer):
|
||||
owner = serializers.PrimaryKeyRelatedField(
|
||||
queryset=CustomUser.objects.all(),
|
||||
required=False
|
||||
)
|
||||
user_info = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Competition
|
||||
fields = ['id', 'owner', 'user', 'user_info', 'name', 'start_date', 'start_date_fmt', 'start_date_epoch', 'end_date', 'end_date_fmt', 'end_date_epoch', 'has_teams', 'organizer_assigns_teams', 'join_code']
|
||||
read_only_fields = ['join_code', 'user', 'user_info']
|
||||
|
||||
def get_user_info(self, obj):
|
||||
# Assuming `obj.user` is a ManyToMany or related manager
|
||||
users = obj.user.all().order_by('username') if hasattr(obj.user, 'all') else [obj.user]
|
||||
return [{'id': u.id, 'username': u.username} for u in users]
|
||||
|
||||
|
||||
class TeamSerializer(serializers.ModelSerializer):
|
||||
user_info = serializers.SerializerMethodField()
|
||||
my = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = Team
|
||||
fields = ['id', 'name', 'competition', 'user', 'user_info', 'my']
|
||||
read_only_fields = ['user', 'user_info', 'my']
|
||||
|
||||
def get_user_info(self, obj):
|
||||
# Assuming `obj.user` is a ManyToMany or related manager
|
||||
users = obj.user.all().order_by('username') if hasattr(obj.user, 'all') else [obj.user]
|
||||
return [{'id': u.id, 'username': u.username} for u in users]
|
||||
|
||||
def get_my(self, obj):
|
||||
# if it is the user's team
|
||||
request = self.context.get('request')
|
||||
if request and hasattr(request, "user"):
|
||||
return obj.user.filter(id=request.user.id).exists()
|
||||
return False
|
||||
|
||||
|
||||
class ActivityGoalSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = ActivityGoal
|
||||
fields = '__all__'
|
||||
read_only_fields = []
|
||||
|
||||
|
||||
class PointsSerializer(serializers.ModelSerializer):
|
||||
class Meta:
|
||||
model = Points
|
||||
fields = ['id', 'goal', 'award', 'workout', 'points_raw', 'points_capped']
|
||||
read_only_fields = []
|
||||
169
src-backend/competition/stats.py
Normal file
169
src-backend/competition/stats.py
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import datetime
|
||||
|
||||
from django.conf import settings
|
||||
from django.apps import apps
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
|
||||
from django.db.models import Sum, Count, ExpressionWrapper, DurationField, F, Q, IntegerField
|
||||
from django.db.models.functions import TruncDay, Now, ExtractDay
|
||||
|
||||
|
||||
def _add_rank(data, key, enhance_dict, id_field, rank_field='rank', reverse=True):
|
||||
sorted_data = sorted(data, key=lambda x: x[key], reverse=reverse)
|
||||
rank = 0
|
||||
last_value = None
|
||||
user_lst = []
|
||||
for idx, item in enumerate(sorted_data, start=1):
|
||||
if item[key] != last_value:
|
||||
rank = idx
|
||||
last_value = item[key]
|
||||
sorted_data[idx-1] = {**item, rank_field: rank, **enhance_dict[item[id_field]]}
|
||||
enhance_dict[item[id_field]]['rank'] = rank
|
||||
enhance_dict[item[id_field]]['points'] = item[key]
|
||||
user_lst.append(item[id_field])
|
||||
for i in [i for i in enhance_dict.keys() if i not in user_lst]:
|
||||
sorted_data.append({**enhance_dict[i], rank_field: None, key: None})
|
||||
enhance_dict[i]['rank'] = None
|
||||
enhance_dict[i]['points'] = None
|
||||
return sorted_data
|
||||
|
||||
|
||||
def get_competition_stats(competition, last_seven_days=False):
|
||||
CustomUser = apps.get_model('custom_user', 'CustomUser')
|
||||
Competition = apps.get_model('competition', 'Competition')
|
||||
Points = apps.get_model('competition', 'Points')
|
||||
Team = apps.get_model('competition', 'Team')
|
||||
|
||||
# Custom query logic
|
||||
try:
|
||||
competition_obj = Competition.objects.get(id=competition)
|
||||
except Competition.DoesNotExist:
|
||||
return Response({"detail": "Competition not found."}, status=status.HTTP_404_NOT_FOUND)
|
||||
all_points = Points.objects.filter(Q(award__competition__id=competition) | Q(goal__competition_id=competition))
|
||||
|
||||
if last_seven_days:
|
||||
today = datetime.date.today()
|
||||
last_sunday = today - datetime.timedelta(days=today.weekday() + 1) if today.weekday() != 6 else today
|
||||
monday_before = last_sunday - datetime.timedelta(days=6)
|
||||
all_points = all_points.filter(workout__start_datetime__gte=monday_before, workout__start_datetime__lt=last_sunday + datetime.timedelta(days=1))
|
||||
|
||||
# For SQLite
|
||||
if settings.DATABASES.get('default', {}).get('ENGINE') == 'django.db.backends.sqlite3':
|
||||
all_points_date = (
|
||||
all_points
|
||||
.annotate(date=TruncDay('workout__start_datetime'))
|
||||
.annotate(tmp_today=TruncDay(Now()))
|
||||
.annotate(tmp_start_date=TruncDay(F('workout__start_datetime')))
|
||||
.annotate(days_ago=ExpressionWrapper((F('tmp_today') - F('tmp_start_date')) / 86_400_000_000, output_field=IntegerField()))
|
||||
)
|
||||
# For Postgres
|
||||
else:
|
||||
all_points_date = (
|
||||
all_points
|
||||
.annotate(date=TruncDay('workout__start_datetime'))
|
||||
.annotate(days_ago_duration=ExpressionWrapper(TruncDay(Now()) - TruncDay(F('workout__start_datetime')), output_field=DurationField()))
|
||||
.annotate(days_ago=ExtractDay(F('days_ago_duration')))
|
||||
)
|
||||
tmp_all = (
|
||||
all_points_date
|
||||
.values('days_ago')
|
||||
.annotate(total=Sum('points_capped'))
|
||||
.values('days_ago', 'total')
|
||||
.order_by('-days_ago')
|
||||
)
|
||||
timeseries_all = {}
|
||||
for i in tmp_all:
|
||||
days_ago = i.pop('days_ago')
|
||||
timeseries_all[days_ago] = i
|
||||
|
||||
tmp_user = (
|
||||
all_points_date
|
||||
.values('days_ago', 'workout__user__id')
|
||||
.annotate(total=Sum('points_capped'))
|
||||
.order_by('-days_ago')
|
||||
)
|
||||
timeseries_user = {}
|
||||
for i in tmp_user:
|
||||
user_id = i.pop('workout__user__id')
|
||||
days_ago = i.pop('days_ago')
|
||||
if user_id not in timeseries_user:
|
||||
timeseries_user[user_id] = {}
|
||||
timeseries_user[user_id][days_ago] = i
|
||||
|
||||
tmp_team = (
|
||||
all_points_date
|
||||
.values('days_ago', 'workout__user__my_teams')
|
||||
.annotate(total=Sum('points_capped'))
|
||||
.order_by('-days_ago')
|
||||
)
|
||||
timeseries_team = {}
|
||||
for i in tmp_team:
|
||||
team_id = i.pop('workout__user__my_teams')
|
||||
days_ago = i.pop('days_ago')
|
||||
if team_id not in timeseries_team:
|
||||
timeseries_team[team_id] = {}
|
||||
timeseries_team[team_id][days_ago] = i
|
||||
|
||||
# Get user data
|
||||
user_dict = {i['id']: i for i in CustomUser.objects.filter(my_competitions=competition).values('id', 'username', 'strava_allow_follow', 'strava_athlete_id').order_by('username', 'id')}
|
||||
for key, value in user_dict.items():
|
||||
if value['strava_allow_follow'] is False:
|
||||
value['strava_athlete_id'] = None
|
||||
|
||||
# Get user rankings
|
||||
leaderboard_user = (
|
||||
all_points
|
||||
.values('workout__user__id')
|
||||
.annotate(total_capped=Sum('points_capped'))
|
||||
.order_by('-total_capped')
|
||||
)
|
||||
leaderboard_user = _add_rank(leaderboard_user, key="total_capped", enhance_dict=user_dict, id_field='workout__user__id')
|
||||
leaderboard_user_dict = {i['id']: i for i in leaderboard_user}
|
||||
|
||||
# Get team data
|
||||
team_dict = {i.id: {'id': i.id, 'name': i.name, 'members': [leaderboard_user_dict.get(i.id, {'id': i.id, 'username': 'ERROR', 'total_capped': None}) for i in i.user.all()]} for i in Team.objects.filter(competition=competition).prefetch_related('user')}
|
||||
for key, value in team_dict.items():
|
||||
value['active_member_count'] = sum(1 for i in value.get('members', []) if i.get('total_capped', 0) is not None and i.get('total_capped', 0) > 0)
|
||||
value['member_count'] = len(value.get('members', []))
|
||||
|
||||
# Get team rankings
|
||||
leaderboard_team = (
|
||||
all_points
|
||||
.values('workout__user__my_teams__id')
|
||||
.annotate(total_capped=Sum('points_capped'))
|
||||
.order_by('-total_capped')
|
||||
)
|
||||
leaderboard_team = [{**i, 'total_capped': i['total_capped'] / max(1, team_dict[i['workout__user__my_teams__id']]['active_member_count'])} for i in leaderboard_team if i['workout__user__my_teams__id'] in team_dict]
|
||||
leaderboard_team = _add_rank(leaderboard_team, key="total_capped", enhance_dict=team_dict, id_field='workout__user__my_teams__id')
|
||||
team_dict = {i['id']: i for i in leaderboard_team}
|
||||
|
||||
competition_details = {
|
||||
'name': competition_obj.name,
|
||||
'owner': user_dict.get(competition_obj.owner.pk, {'id': competition_obj.owner.pk, 'username': 'ERROR', 'total_capped': None}),
|
||||
'members': [user_dict.get(i, {'id': i, 'username': 'ERROR', 'total_capped': None}) for i in list(competition_obj.user.all().values_list('pk', flat=True))],
|
||||
'member_count': competition_obj.user.all().count(),
|
||||
'active_member_count': len(timeseries_user),
|
||||
'start_date': competition_obj.start_date,
|
||||
'start_date_count': (datetime.date.today() - competition_obj.start_date).days,
|
||||
'end_date': competition_obj.end_date,
|
||||
'end_date_count': (datetime.date.today() - competition_obj.end_date).days,
|
||||
'has_teams': competition_obj.has_teams,
|
||||
'goals': competition_obj.activitygoal_set.all().values(),
|
||||
}
|
||||
|
||||
response_obj = {
|
||||
'competition': competition_details,
|
||||
'users': user_dict,
|
||||
'teams': team_dict,
|
||||
'timeseries': {
|
||||
'all': timeseries_all,
|
||||
'user': timeseries_user,
|
||||
'team': timeseries_team,
|
||||
},
|
||||
'leaderboard': {
|
||||
'team': leaderboard_team,
|
||||
'individual': leaderboard_user,
|
||||
}
|
||||
}
|
||||
return response_obj
|
||||
3
src-backend/competition/tests.py
Normal file
3
src-backend/competition/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
306
src-backend/competition/views.py
Normal file
306
src-backend/competition/views.py
Normal file
|
|
@ -0,0 +1,306 @@
|
|||
from django.conf import settings
|
||||
from django.db.models import Q
|
||||
from django.core.exceptions import PermissionDenied
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.permissions import AllowAny, IsAuthenticated
|
||||
from rest_framework import status
|
||||
from django.utils.decorators import method_decorator
|
||||
from django.views.decorators.cache import cache_page
|
||||
from rest_framework.permissions import BasePermission
|
||||
|
||||
from django.db.models import Sum
|
||||
|
||||
from custom_user.views import IsOwnerOrReadOnly
|
||||
from custom_user.models import CustomUser
|
||||
from custom_user.strava import sync_strava
|
||||
from custom_user.point_recalc import recalc_points
|
||||
from .models import Competition, Team, ActivityGoal, Points
|
||||
from .serializers import CompetitionSerializer, TeamSerializer, ActivityGoalSerializer, PointsSerializer
|
||||
from .stats import get_competition_stats
|
||||
|
||||
from celery import current_app
|
||||
import json
|
||||
|
||||
class CompetitionViewSet(viewsets.ModelViewSet):
|
||||
#queryset = Competition.objects.all()
|
||||
serializer_class = CompetitionSerializer
|
||||
|
||||
permission_classes = [IsOwnerOrReadOnly]
|
||||
|
||||
def get_queryset(self):
|
||||
# return all competitions the user is owner of or a participant of
|
||||
#time.sleep(3) # throttle for testing
|
||||
return Competition.objects.filter(Q(owner=self.request.user) | Q(user=self.request.user)).distinct().order_by('-end_date', '-start_date', '-id')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
# when creating a new competition, set the owner to the request user
|
||||
serializer.save(owner=self.request.user)
|
||||
|
||||
|
||||
class TeamViewSet(viewsets.ModelViewSet):
|
||||
#queryset = Team.objects.all()
|
||||
serializer_class = TeamSerializer
|
||||
|
||||
permission_classes = [IsOwnerOrReadOnly]
|
||||
|
||||
def get_queryset(self):
|
||||
# return all teams the user is a member of and all teams of competitions the user participates in
|
||||
#time.sleep(3) # throttle for testing
|
||||
return Team.objects.filter(Q(user=self.request.user) | Q(competition__user=self.request.user)).distinct().order_by('name')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
|
||||
competition_obj = serializer.validated_data.get('competition')
|
||||
|
||||
# if has_teams is disabled, don't allow creation of teams
|
||||
if competition_obj.has_teams is False:
|
||||
raise PermissionDenied("Teams are disabled for this competition.")
|
||||
|
||||
# only allow user to create a team if they are a member or owner of the competition
|
||||
if not (competition_obj.owner == self.request.user) and not (competition_obj in self.request.user.my_competitions.all()):
|
||||
raise PermissionDenied("You are not a participant of the competition you want to create a team for.")
|
||||
|
||||
serializer.save()
|
||||
|
||||
|
||||
class ActivityGoalViewSet(viewsets.ModelViewSet):
|
||||
#queryset = ActivityGoal.objects.all()
|
||||
serializer_class = ActivityGoalSerializer
|
||||
|
||||
permission_classes = [IsOwnerOrReadOnly]
|
||||
|
||||
def get_queryset(self):
|
||||
# return all competition categories the user is owner of or a participant of
|
||||
#time.sleep(3) # throttle for testing
|
||||
return ActivityGoal.objects.filter(Q(competition__owner=self.request.user) | Q(competition__user=self.request.user)).distinct().order_by('name')
|
||||
|
||||
def perform_create(self, serializer):
|
||||
competition_obj = serializer.validated_data.get('competition')
|
||||
|
||||
# only allow user to create a team if they are a member or owner of the competition
|
||||
if competition_obj.owner != self.request.user:
|
||||
raise PermissionDenied("You can only create and edit competition goals if you are the owner.")
|
||||
|
||||
serializer.save()
|
||||
|
||||
|
||||
class PointsViewSet(viewsets.ModelViewSet):
|
||||
#queryset = Points.objects.all()
|
||||
serializer_class = PointsSerializer
|
||||
|
||||
permission_classes = [IsOwnerOrReadOnly]
|
||||
|
||||
def get_queryset(self):
|
||||
# return all points the user is owner of, a participant of, or of his/her own workouts
|
||||
#time.sleep(3) # throttle for testing
|
||||
return Points.objects.filter(Q(goal__competition__owner=self.request.user) | Q(goal__competition__user=self.request.user) | Q(workout__user=self.request.user)).distinct().order_by('-workout__start_datetime', '-workout__duration', '-workout', '-workout__user')
|
||||
|
||||
|
||||
class StatsPermissions(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
# Only authenticated users
|
||||
if request.user.is_authenticated:
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
competition_lst = Competition.objects.filter(
|
||||
Q(pk=view.kwargs.get('competition', 0)) & (Q(owner=request.user) | Q(user=request.user))
|
||||
)
|
||||
return len(competition_lst) > 0
|
||||
|
||||
|
||||
class IsAdmin(BasePermission):
|
||||
"""
|
||||
Custom permission class to allow access only to admin users.
|
||||
"""
|
||||
def has_permission(self, request, view):
|
||||
# Check if user is authenticated and is an admin
|
||||
return bool(request.user and request.user.is_authenticated and request.user.is_staff)
|
||||
|
||||
|
||||
class CeleryQueryView(APIView):
|
||||
permission_classes = [IsAdmin]
|
||||
|
||||
def get(self, request, task_id=None):
|
||||
if task_id:
|
||||
# Get status of specific task
|
||||
try:
|
||||
task = current_app.AsyncResult(task_id)
|
||||
return Response({
|
||||
'task_id': task.id,
|
||||
'status': task.status,
|
||||
'result': task.result if task.successful() else None,
|
||||
'error': str(task.result) if task.failed() else None
|
||||
})
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"error": f"Error retrieving task status: {str(e)}"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
else:
|
||||
# List all registered tasks
|
||||
try:
|
||||
registered_tasks = [
|
||||
name
|
||||
for name, task in sorted(current_app.tasks.items())
|
||||
if not name.startswith('celery.')
|
||||
]
|
||||
return Response(registered_tasks)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"error": f"Error retrieving tasks: {str(e)}"},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
def post(self, request):
|
||||
task = request.query_params.get('task')
|
||||
args = request.query_params.get('args', '[]')
|
||||
kwargs = request.query_params.get('kwargs', '{}')
|
||||
|
||||
if not task:
|
||||
return Response(
|
||||
{"error": "Task name is required"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
|
||||
try:
|
||||
# Convert string args and kwargs to Python objects
|
||||
args_list = json.loads(args)
|
||||
kwargs_dict = json.loads(kwargs)
|
||||
|
||||
# Get the task by name and apply it with args and kwargs
|
||||
celery_task = current_app.tasks[task]
|
||||
result = celery_task.delay(*args_list, **kwargs_dict)
|
||||
|
||||
return Response({
|
||||
"task_id": result.task_id,
|
||||
"status": "Task sent successfully"
|
||||
})
|
||||
|
||||
except json.JSONDecodeError:
|
||||
return Response(
|
||||
{"error": "Invalid JSON format in args or kwargs"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except KeyError:
|
||||
return Response(
|
||||
{"error": f"Task '{task}' not found"},
|
||||
status=status.HTTP_400_BAD_REQUEST
|
||||
)
|
||||
except Exception as e:
|
||||
return Response(
|
||||
{"error": str(e)},
|
||||
status=status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||
)
|
||||
|
||||
|
||||
class CompetitionStatsQueryView(APIView):
|
||||
permission_classes = [StatsPermissions]
|
||||
|
||||
@method_decorator(cache_page(30)) # cache for 30 seconds
|
||||
def get(self, request, competition):
|
||||
response_obj = get_competition_stats(competition)
|
||||
self.check_object_permissions(request, response_obj)
|
||||
return Response(response_obj)
|
||||
|
||||
|
||||
class FeedPermissions(BasePermission):
|
||||
def has_permission(self, request, view):
|
||||
# Only authenticated users
|
||||
if request.user.is_authenticated:
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
if len(obj) == 0:
|
||||
return False
|
||||
obj = obj[0]
|
||||
return request.user.id in [obj.owner.pk] + list(obj.user.all().values_list('pk', flat=True))
|
||||
|
||||
|
||||
class FeedQueryView(APIView):
|
||||
""" API view to get the activity/point feed for a competition. """
|
||||
permission_classes = [FeedPermissions]
|
||||
|
||||
def get(self, request, competition):
|
||||
# Custom query logic
|
||||
#time.sleep(3) # throttle for testing
|
||||
|
||||
competition_obj = Competition.objects.filter(id=competition)
|
||||
self.check_object_permissions(request, competition_obj)
|
||||
|
||||
all_points = Points.objects.filter(Q(award__competition__id=competition) | Q(goal__competition_id=competition)).order_by('-workout__start_datetime', '-workout__steps', '-workout__duration', '-workout', '-workout__user')
|
||||
|
||||
grouped_points = {i['workout']: i for i in all_points.values('workout__user', 'workout__user__username', 'workout__user__strava_allow_follow', 'workout', 'workout__sport_type', 'workout__start_datetime', 'workout__duration', 'workout__steps', 'workout__strava_id', 'award').annotate(points_capped=Sum('points_capped'), points_raw=Sum('points_raw')).order_by('-workout__start_datetime', '-workout__duration', '-workout', '-workout__user')}
|
||||
|
||||
for i in all_points.values('workout', 'id', 'goal', 'goal__name', 'award', 'award__name', 'points_capped', 'points_raw'):
|
||||
if 'details' not in grouped_points[i['workout']]:
|
||||
grouped_points[i['workout']]['details'] = []
|
||||
grouped_points[i['workout']]['details'].append(i)
|
||||
|
||||
return Response(list(grouped_points.values()))
|
||||
|
||||
|
||||
|
||||
class JoinCompetitionView(APIView):
|
||||
""" API post view for users to join a competition. """
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request, join_code):
|
||||
competition = Competition.objects.filter(join_code=join_code.upper())
|
||||
if len(competition) == 0:
|
||||
return Response({"message": "Invalid join code."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
competition = competition[0]
|
||||
competition.user.add(request.user)
|
||||
competition.save()
|
||||
return Response({"message": "Successfully joined competition.", "competition": competition.id}, status=status.HTTP_200_OK)
|
||||
|
||||
def delete(self, request, join_code):
|
||||
id = int(join_code)
|
||||
|
||||
request.user.my_competitions.remove(id)
|
||||
teams = request.user.my_teams.filter(competition=id)
|
||||
for team in teams:
|
||||
team.user.remove(request.user)
|
||||
team.save()
|
||||
request.user.save()
|
||||
|
||||
points = Points.objects.filter((Q(award__competition__id=id) | Q(goal__competition_id=id)) & Q(workout__user=request.user))
|
||||
points.delete()
|
||||
|
||||
return Response({"message": "Successfully left competition.", "competition": id}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class JoinTeamView(APIView):
|
||||
""" API post view for users to join a team and make sure they are only a member of one team per competition. """
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
team_id = request.query_params.get('team')
|
||||
team = Team.objects.filter(id=team_id)
|
||||
if len(team) == 0:
|
||||
return Response({"message": "Invalid team id."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
team = team[0]
|
||||
|
||||
user_id = request.query_params.get('user', request.user.id)
|
||||
user = CustomUser.objects.filter(id=user_id)
|
||||
if len(user) == 0:
|
||||
return Response({"message": "Invalid user id."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
user = user[0]
|
||||
|
||||
competition = team.competition
|
||||
competition_teams = competition.team_set.all()
|
||||
|
||||
if user != request.user and request.user != competition.owner and len(competition_teams.filter(user=user)) > 0:
|
||||
return Response({"message": "Unauthorized. You can only change your own team or add people to your team if they are currently in no team."}, status=status.HTTP_403_FORBIDDEN)
|
||||
|
||||
for competition_team in competition_teams:
|
||||
competition_team.user.remove(user.id)
|
||||
competition_team.save()
|
||||
user.my_teams.add(team.id)
|
||||
user.save()
|
||||
|
||||
return Response({"message": "Successfully joined team.", "team": team.id, "user": user.id}, status=status.HTTP_200_OK)
|
||||
0
src-backend/custom_user/__init__.py
Normal file
0
src-backend/custom_user/__init__.py
Normal file
25
src-backend/custom_user/admin.py
Normal file
25
src-backend/custom_user/admin.py
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
from django.contrib import admin
|
||||
|
||||
from .models import CustomUser, RecalcRequest
|
||||
|
||||
# Register your models here.
|
||||
@admin.register(CustomUser)
|
||||
class CustomUserAdmin(admin.ModelAdmin):
|
||||
"""Admin view of CustomUser"""
|
||||
|
||||
list_display = [
|
||||
"username",
|
||||
"first_name",
|
||||
"last_name",
|
||||
]
|
||||
|
||||
@admin.register(RecalcRequest)
|
||||
class RecalcRequestAdmin(admin.ModelAdmin):
|
||||
"""Admin view of RecalcRequest"""
|
||||
|
||||
list_display = [
|
||||
"user",
|
||||
"goal",
|
||||
"start_datetime",
|
||||
"done",
|
||||
]
|
||||
71
src-backend/custom_user/api_rate_limiter.py
Normal file
71
src-backend/custom_user/api_rate_limiter.py
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
# myapp/monitor.py
|
||||
from datetime import datetime, timezone
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
class RateLimitExceeded(Exception):
|
||||
"""Raised when the API rate limit is exceeded."""
|
||||
pass
|
||||
|
||||
class APIRequestMonitor:
|
||||
""" API request rate limiter"""
|
||||
def __init__(self, limit_15min: int, limit_day: int):
|
||||
self.limit_15min = limit_15min
|
||||
self.limit_day = limit_day
|
||||
self.current_15min_slot = self._get_15min_slot()
|
||||
self.current_day = self._get_day()
|
||||
self.count_15min = 0
|
||||
self.count_day = 0
|
||||
|
||||
def _get_15min_slot(self):
|
||||
now = datetime.now(timezone.utc)
|
||||
return now.replace(minute=(now.minute // 15) * 15, second=0, microsecond=0)
|
||||
|
||||
def _get_day(self):
|
||||
return datetime.now(timezone.utc).date()
|
||||
|
||||
def _maybe_reset_counters(self):
|
||||
now_slot = self._get_15min_slot()
|
||||
today = self._get_day()
|
||||
|
||||
if now_slot != self.current_15min_slot:
|
||||
self.current_15min_slot = now_slot
|
||||
self.count_15min = 0
|
||||
|
||||
if today != self.current_day:
|
||||
self.current_day = today
|
||||
self.count_day = 0
|
||||
|
||||
def log_request(self, response) -> bool:
|
||||
self._maybe_reset_counters()
|
||||
self.count_day += 1
|
||||
|
||||
if response.status_code == 429:
|
||||
self.count_15min = self.limit_15min
|
||||
raise RateLimitExceeded("API rate limit exceeded")
|
||||
|
||||
if self.count_15min >= self.limit_15min or self.count_day >= self.limit_day:
|
||||
raise RateLimitExceeded("API rate limit probably exceeded")
|
||||
|
||||
self.count_15min += 1
|
||||
print(f'Strava API Request (15min: {self.count_15min} / {self.limit_15min}, day: {self.count_day} / {self.limit_day})')
|
||||
return True
|
||||
|
||||
def count_requests(self):
|
||||
self._maybe_reset_counters()
|
||||
return {
|
||||
"requests_15min": self.count_15min,
|
||||
"requests_today": self.count_day
|
||||
}
|
||||
|
||||
def ok_workout_requests(self):
|
||||
stats = self.count_requests()
|
||||
return ((stats["requests_today"] <= self.limit_day * 0.8) & (stats["requests_15min"] <= self.limit_15min * 0.66))
|
||||
|
||||
def ok_linkage_requests(self):
|
||||
stats = self.count_requests()
|
||||
return ((stats["requests_today"] <= self.limit_day) & (stats["requests_15min"] <= self.limit_15min))
|
||||
|
||||
|
||||
# Singleton instance
|
||||
strava_api_monitor = APIRequestMonitor(limit_15min=settings.STRAVA_LIMIT_15MIN, limit_day=settings.STRAVA_LIMIT_DAY)
|
||||
6
src-backend/custom_user/apps.py
Normal file
6
src-backend/custom_user/apps.py
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CustomUserConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'custom_user'
|
||||
0
src-backend/custom_user/emails/__init__.py
Normal file
0
src-backend/custom_user/emails/__init__.py
Normal file
336
src-backend/custom_user/emails/celery_emails.py
Normal file
336
src-backend/custom_user/emails/celery_emails.py
Normal file
|
|
@ -0,0 +1,336 @@
|
|||
import datetime, random
|
||||
from openai import OpenAI
|
||||
from django.core.cache import cache
|
||||
from django.conf import settings
|
||||
from django.apps import apps
|
||||
from django.template.loader import render_to_string
|
||||
from workout_challenge.celery import app
|
||||
from django.db.models import Sum, Count, Q
|
||||
from django.db.models.functions import TruncDate, TruncDay
|
||||
|
||||
from .multipurpose import send_email
|
||||
from competition.stats import get_competition_stats
|
||||
|
||||
|
||||
@app.task()
|
||||
def welcome_email(user_pk):
|
||||
"""Welcome email for new users."""
|
||||
CustomUser = apps.get_model('custom_user', 'CustomUser')
|
||||
user_obj = CustomUser.objects.get(pk=user_pk)
|
||||
|
||||
email_subject = 'Welcome to the Workout Challenge!'
|
||||
|
||||
email_body = render_to_string(
|
||||
"email_welcome.html",
|
||||
{
|
||||
'first_name': user_obj.first_name,
|
||||
'MAIN_HOST': settings.MAIN_HOST,
|
||||
'EMAIL_REPLY_TO': settings.EMAIL_REPLY_TO[0] if settings.EMAIL_REPLY_TO is not None else settings.EMAIL_FROM,
|
||||
'link_strava_note': user_obj.strava_refresh_token is None or user_obj.strava_refresh_token == '',
|
||||
}
|
||||
)
|
||||
|
||||
if settings.DEBUG:
|
||||
with open('tmp_email.html', 'w') as file:
|
||||
file.write(email_body)
|
||||
|
||||
send_email(subject=email_subject, body=email_body, to_email=user_obj.email)
|
||||
|
||||
return {'pk': user_obj.pk, 'username': user_obj.username, 'email': user_obj.email}
|
||||
|
||||
|
||||
@app.task()
|
||||
def send_all_log_workouts_email():
|
||||
print("Scheduling log workout emails...")
|
||||
CustomUser = apps.get_model('custom_user', 'CustomUser')
|
||||
user_lst = CustomUser.objects.filter(
|
||||
Q(my_competitions__start_date__lte=datetime.date.today()) &
|
||||
Q(my_competitions__end_date__gte=datetime.date.today())
|
||||
).order_by('pk')
|
||||
task_log = []
|
||||
if len(user_lst) > 0:
|
||||
eta_steps = max(min((60 * 60) // len(user_lst), 60), 10)
|
||||
eta = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=10)
|
||||
for user_obj in user_lst:
|
||||
result = log_workouts_email.apply_async(args=[user_obj.pk], eta=eta)
|
||||
task_log.append({'pk': user_obj.pk, 'username': user_obj.username, 'email': user_obj.email, 'task_id': result.task_id, 'eta': eta.isoformat()})
|
||||
eta += datetime.timedelta(seconds=eta_steps)
|
||||
return task_log
|
||||
|
||||
|
||||
@app.task()
|
||||
def log_workouts_email(user_pk):
|
||||
"""Email reminder for users to please log their workouts."""
|
||||
CustomUser = apps.get_model('custom_user', 'CustomUser')
|
||||
user_obj = CustomUser.objects.get(pk=user_pk)
|
||||
workout_obj_lst = user_obj.workout_set.order_by('-start_datetime')[:3]
|
||||
|
||||
email_subject = 'Workout Challenge - Log Your Workouts!'
|
||||
|
||||
email_body = render_to_string(
|
||||
"email_log_workouts.html",
|
||||
{
|
||||
'first_name': user_obj.first_name,
|
||||
'last_workouts': workout_obj_lst,
|
||||
'link_strava_note': user_obj.strava_refresh_token is None or user_obj.strava_refresh_token == '',
|
||||
'MAIN_HOST': settings.MAIN_HOST,
|
||||
'EMAIL_REPLY_TO': settings.EMAIL_REPLY_TO[0] if settings.EMAIL_REPLY_TO is not None else settings.EMAIL_FROM,
|
||||
}
|
||||
)
|
||||
|
||||
if settings.DEBUG:
|
||||
with open('tmp_email.html', 'w') as file:
|
||||
file.write(email_body)
|
||||
|
||||
send_email(subject=email_subject, body=email_body, to_email=user_obj.email)
|
||||
|
||||
return {'pk': user_obj.pk, 'username': user_obj.username, 'email': user_obj.email}
|
||||
|
||||
|
||||
@app.task()
|
||||
def send_all_competition_start_email():
|
||||
print("Scheduling competition start emails...")
|
||||
Competition = apps.get_model('competition', 'Competition')
|
||||
competition_lst = Competition.objects.filter(start_date=datetime.date.today() + datetime.timedelta(days=1)).order_by('pk')
|
||||
task_log = []
|
||||
for i, competition_obj in enumerate(competition_lst):
|
||||
eta = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=10) + datetime.timedelta(minutes=(15 * i))
|
||||
user_lst = competition_obj.user.all().order_by('pk')
|
||||
if len(user_lst) > 0:
|
||||
eta_steps = max(min((60 * 60) // len(user_lst), 60), 10)
|
||||
for user_obj in user_lst:
|
||||
result = competition_start_email.apply_async(args=[competition_obj.pk, user_obj.pk], eta=eta)
|
||||
task_log.append({'user_pk': user_obj.pk, 'username': user_obj.username, 'email': user_obj.email, 'competition_pk': competition_obj.pk, 'competition_name': competition_obj.name, 'task_id': result.task_id, 'eta': eta.isoformat()})
|
||||
eta += datetime.timedelta(seconds=eta_steps)
|
||||
return task_log
|
||||
|
||||
|
||||
@app.task()
|
||||
def competition_start_email(competition_pk, user_pk):
|
||||
"""Email for competition start tomorrow."""
|
||||
Competition = apps.get_model('competition', 'Competition')
|
||||
competition_obj = Competition.objects.get(pk=competition_pk)
|
||||
goal_objs = competition_obj.activitygoal_set.all()
|
||||
|
||||
CustomUser = apps.get_model('custom_user', 'CustomUser')
|
||||
user_obj = CustomUser.objects.get(pk=user_pk)
|
||||
|
||||
email_subject = 'Workout Challenge - READY, SET, GO!'
|
||||
|
||||
email_body = render_to_string(
|
||||
"email_competition_start.html",
|
||||
{
|
||||
'first_name': user_obj.first_name,
|
||||
'MAIN_HOST': settings.MAIN_HOST,
|
||||
'competition': competition_obj,
|
||||
'goals': goal_objs,
|
||||
'EMAIL_REPLY_TO': settings.EMAIL_REPLY_TO[0] if settings.EMAIL_REPLY_TO is not None else settings.EMAIL_FROM,
|
||||
'goal_equalizer_note': user_obj.scaling_kcal == 1 and user_obj.scaling_distance == 1,
|
||||
}
|
||||
)
|
||||
|
||||
if settings.DEBUG:
|
||||
with open('tmp_email.html', 'w') as file:
|
||||
file.write(email_body)
|
||||
|
||||
send_email(subject=email_subject, body=email_body, to_email=user_obj.email)
|
||||
|
||||
return ({'pk': user_obj.pk, 'username': user_obj.username, 'email': user_obj.email})
|
||||
|
||||
|
||||
@app.task()
|
||||
def send_all_leaderboard_emails():
|
||||
print("Scheduling leaderboard emails...")
|
||||
CustomUser = apps.get_model('custom_user', 'CustomUser')
|
||||
user_lst = CustomUser.objects.filter(my_competitions__start_date__lt=datetime.date.today(), my_competitions__end_date__gte=datetime.date.today()).order_by('pk')
|
||||
task_log = []
|
||||
if len(user_lst) > 0:
|
||||
eta_steps = max(min((60 * 60) // len(user_lst), 60), 10)
|
||||
eta = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=10)
|
||||
for user_obj in user_lst:
|
||||
result = leaderboard_email.apply_async(args=[user_obj.pk], eta=eta)
|
||||
task_log.append({'pk': user_obj.pk, 'username': user_obj.username, 'email': user_obj.email, 'task_id': result.task_id, 'eta': eta.isoformat()})
|
||||
eta += datetime.timedelta(seconds=eta_steps)
|
||||
return task_log
|
||||
|
||||
|
||||
@app.task()
|
||||
def leaderboard_email(user_pk):
|
||||
"""Email to send users their leaderboard."""
|
||||
CustomUser = apps.get_model('custom_user', 'CustomUser')
|
||||
user_obj = CustomUser.objects.get(pk=user_pk)
|
||||
|
||||
competition_all_data = []
|
||||
competition_7d_data = []
|
||||
|
||||
for competition in user_obj.my_competitions.filter(start_date__lte=datetime.date.today(), end_date__gte=datetime.date.today()).order_by('-start_date'):
|
||||
competition_all_stats = get_competition_stats(competition.pk)
|
||||
competition_all_data.append({
|
||||
'competition': competition_all_stats['competition'],
|
||||
'leaderboard': competition_all_stats['leaderboard'],
|
||||
})
|
||||
competition_7d_stats = get_competition_stats(competition.pk, last_seven_days=True)
|
||||
competition_7d_data.append({
|
||||
'competition': competition_7d_stats['competition'],
|
||||
'leaderboard': competition_7d_stats['leaderboard'],
|
||||
})
|
||||
|
||||
email_subject = 'Workout Challenge - Your Spot on the Leaderboard!'
|
||||
|
||||
email_body = render_to_string(
|
||||
"email_leaderboard.html",
|
||||
{
|
||||
'first_name': user_obj.first_name,
|
||||
'MAIN_HOST': settings.MAIN_HOST,
|
||||
'competitions_all': competition_all_data,
|
||||
'competitions_7d': competition_7d_data,
|
||||
'EMAIL_REPLY_TO': settings.EMAIL_REPLY_TO[0] if settings.EMAIL_REPLY_TO is not None else settings.EMAIL_FROM,
|
||||
'goal_equalizer_note': user_obj.scaling_kcal == 1 and user_obj.scaling_distance == 1,
|
||||
}
|
||||
)
|
||||
|
||||
if settings.DEBUG:
|
||||
with open('tmp_email.html', 'w') as file:
|
||||
file.write(email_body)
|
||||
|
||||
send_email(subject=email_subject, body=email_body, to_email=user_obj.email)
|
||||
|
||||
return ({'pk': user_obj.pk, 'username': user_obj.username, 'email': user_obj.email})
|
||||
|
||||
|
||||
@app.task()
|
||||
def send_all_weekly_emails():
|
||||
print("Scheduling weekly emails...")
|
||||
CustomUser = apps.get_model('custom_user', 'CustomUser')
|
||||
user_lst = CustomUser.objects.filter(email_mid_week=True).order_by('pk')
|
||||
task_log = []
|
||||
if len(user_lst) > 0:
|
||||
eta_steps = max(min((60 * 60) // len(user_lst), 60), 10)
|
||||
eta = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=10)
|
||||
for user_obj in user_lst:
|
||||
result = weekly_email.apply_async(args=[user_obj.pk], eta=eta)
|
||||
task_log.append({'pk': user_obj.pk, 'username': user_obj.username, 'email': user_obj.email, 'task_id': result.task_id, 'eta': eta.isoformat()})
|
||||
eta += datetime.timedelta(seconds=eta_steps)
|
||||
return task_log
|
||||
|
||||
|
||||
def openai_quote():
|
||||
|
||||
if settings.OPENAI_API_KEY is None:
|
||||
return None
|
||||
|
||||
todays_ai_quote = cache.get('todays_ai_quote', None)
|
||||
|
||||
if todays_ai_quote is None:
|
||||
client = OpenAI(api_key=settings.OPENAI_API_KEY)
|
||||
options = ["fitness", "health", "nutritional", "workout"]
|
||||
selection = random.choice(options)
|
||||
response = client.chat.completions.create(
|
||||
model="gpt-4o",
|
||||
messages=[
|
||||
{"role": "user", "content": f"Tell me a one sentence {selection} fact."},
|
||||
],
|
||||
temperature=1.0,
|
||||
top_p=1.0
|
||||
)
|
||||
todays_ai_quote = response.choices[0].message.content
|
||||
cache.set('todays_ai_quote', todays_ai_quote, 60 * 60 * 20)
|
||||
|
||||
print('Todays AI Quote:', todays_ai_quote)
|
||||
|
||||
return todays_ai_quote
|
||||
|
||||
|
||||
def calendar_stats(user_pk):
|
||||
today = datetime.date.today()
|
||||
|
||||
# Step 1: Find next Sunday (or today if Sunday)
|
||||
days_until_sunday = (6 - today.weekday()) % 7 # weekday(): Monday=0, Sunday=6
|
||||
next_sunday = today + datetime.timedelta(days=days_until_sunday)
|
||||
|
||||
# Step 2: Create list of days going back 5 weeks (inclusive)
|
||||
dates_list = [next_sunday - datetime.timedelta(days=i) for i in range(34, -1, -1)]
|
||||
|
||||
Workout = apps.get_model('workouts', 'Workout')
|
||||
all_workouts = Workout.objects.filter(user=user_pk).annotate(date=TruncDay('start_datetime')).values('date').annotate(count=Count('id'))
|
||||
workouts_by_date = {row['date'].date().isoformat(): row['count'] for row in all_workouts}
|
||||
workouts_by_week = {(settings.TIME_ZONE_OBJ.localize(datetime.datetime.combine(next_sunday, datetime.datetime.min.time())) - row['date']).days // 7: True for row in all_workouts}
|
||||
|
||||
streak_weeks = 0
|
||||
streak_i = 0
|
||||
streak_true = True
|
||||
while streak_true:
|
||||
if workouts_by_week.get(streak_i, False):
|
||||
streak_weeks += 1
|
||||
elif streak_i == 0:
|
||||
pass
|
||||
else:
|
||||
streak_true = False
|
||||
streak_i += 1
|
||||
|
||||
return_calendar = []
|
||||
for date in dates_list:
|
||||
workout_num = workouts_by_date.get(date.isoformat(), 0)
|
||||
return_calendar.append({
|
||||
'datetime': date,
|
||||
'day': date.day,
|
||||
'workout_num': workout_num,
|
||||
'color': '#FFFFFF' if date == today or workout_num > 0 else ('#e5e5e5' if date > today else '#000000'),
|
||||
'background_color': '#7F1D1D' if date == today else ('#075971' if workout_num > 0 else '#FFFFFF')
|
||||
})
|
||||
|
||||
return streak_weeks, [return_calendar[i:i+7] for i in range(0, len(return_calendar), 7)]
|
||||
|
||||
|
||||
@app.task()
|
||||
def weekly_email(user_pk):
|
||||
"""Email to send users their weekly update."""
|
||||
CustomUser = apps.get_model('custom_user', 'CustomUser')
|
||||
user_obj = CustomUser.objects.get(pk=user_pk)
|
||||
|
||||
Workout = apps.get_model('workouts', 'Workout')
|
||||
workout_7day_stats = Workout.objects.filter(
|
||||
user=user_obj,
|
||||
start_datetime__gte=datetime.date.today() - datetime.timedelta(days=7)
|
||||
).annotate(
|
||||
day=TruncDate('start_datetime')
|
||||
).aggregate(
|
||||
total_duration=Sum('duration'),
|
||||
total_distance=Sum('distance'),
|
||||
distinct_days=Count('day', distinct=True)
|
||||
)
|
||||
|
||||
week_streak, calendar = calendar_stats(user_pk)
|
||||
|
||||
todays_ai_quote = openai_quote()
|
||||
|
||||
email_subject = 'Workout Challenge - Your Weekly Update!'
|
||||
|
||||
recorded_total_duration = 0 if workout_7day_stats["total_duration"] is None else (workout_7day_stats["total_duration"].seconds // 60)
|
||||
recorded_total_distance = 0 if workout_7day_stats["total_distance"] is None else workout_7day_stats["total_distance"]
|
||||
recorded_distinct_days = 0 if workout_7day_stats["distinct_days"] is None else workout_7day_stats["distinct_days"]
|
||||
|
||||
email_body = render_to_string(
|
||||
"email_weekly.html",
|
||||
{
|
||||
'first_name': user_obj.first_name,
|
||||
'MAIN_HOST': settings.MAIN_HOST,
|
||||
'calendar': calendar,
|
||||
'week_streak': week_streak,
|
||||
'goals': {
|
||||
'active_days': None if user_obj.goal_active_days is None or user_obj.goal_active_days == '' else {'recorded': recorded_distinct_days,'target': user_obj.goal_active_days, 'percent': min(1, recorded_distinct_days / user_obj.goal_active_days) * 100, 'percent_vml': int(min(1, recorded_distinct_days / user_obj.goal_active_days) * 100 * 2.5)},
|
||||
'distance': None if user_obj.goal_distance is None or user_obj.goal_distance == '' else {'recorded': recorded_total_distance,'target': user_obj.goal_distance, 'percent': min(1, recorded_total_distance / user_obj.goal_distance) * 100, 'percent_vml': int(min(1, recorded_total_distance / user_obj.goal_distance) * 100 * 2.5)},
|
||||
'minutes': None if user_obj.goal_workout_minutes is None or user_obj.goal_workout_minutes == '' else {'recorded': recorded_total_duration,'target': user_obj.goal_workout_minutes, 'percent': min(1, recorded_total_duration / user_obj.goal_workout_minutes) * 100, 'percent_vml': int(min(1, recorded_total_duration / user_obj.goal_workout_minutes) * 100 * 2.5)},
|
||||
},
|
||||
'openai_quote': todays_ai_quote,
|
||||
'EMAIL_REPLY_TO': settings.EMAIL_REPLY_TO[0] if settings.EMAIL_REPLY_TO is not None else settings.EMAIL_FROM,
|
||||
}
|
||||
)
|
||||
|
||||
if settings.DEBUG:
|
||||
with open('tmp_email.html', 'w') as file:
|
||||
file.write(email_body)
|
||||
|
||||
send_email(subject=email_subject, body=email_body, to_email=user_obj.email)
|
||||
|
||||
return {'pk': user_obj.pk, 'username': user_obj.username, 'email': user_obj.email}
|
||||
23
src-backend/custom_user/emails/multipurpose.py
Normal file
23
src-backend/custom_user/emails/multipurpose.py
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import os
|
||||
from django.conf import settings
|
||||
from django.core.mail import get_connection
|
||||
from django.core.mail.message import EmailMultiAlternatives
|
||||
|
||||
|
||||
|
||||
def send_email(subject, body, to_email, cc=[], reply_to=[]):
|
||||
"""General function via which all emails are sent out"""
|
||||
to_email = [settings.EMAIL_FROM] if (settings.DEBUG or '.local' in to_email.lower()) else [to_email]
|
||||
from_email = settings.EMAIL_FROM
|
||||
reply_to_email = ([from_email] if settings.EMAIL_REPLY_TO is None else settings.EMAIL_REPLY_TO) if reply_to == [] else reply_to
|
||||
|
||||
print(f'Email Server: {settings.EMAIL_HOST}')
|
||||
connection = get_connection()
|
||||
mail = EmailMultiAlternatives(
|
||||
subject=subject, body="", from_email=from_email, to=to_email, cc=cc, reply_to=reply_to_email, connection=connection
|
||||
)
|
||||
mail.attach_alternative(body, "text/html")
|
||||
mail.content_subtype = "html"
|
||||
|
||||
mail.send()
|
||||
print(f'Email "{subject}" sent to {to_email}')
|
||||
15
src-backend/custom_user/filters.py
Normal file
15
src-backend/custom_user/filters.py
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
# filters.py
|
||||
import django_filters
|
||||
from .models import CustomUser
|
||||
|
||||
class CustomUserFilter(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 = CustomUser
|
||||
fields = {
|
||||
'username': ['exact', 'icontains'],
|
||||
}
|
||||
232
src-backend/custom_user/management/commands/add_dummy_data.py
Normal file
232
src-backend/custom_user/management/commands/add_dummy_data.py
Normal file
|
|
@ -0,0 +1,232 @@
|
|||
from django.core.management import BaseCommand
|
||||
import datetime, random
|
||||
from datetime import timedelta
|
||||
|
||||
from competition.models import Competition, ActivityGoal, Team, Award, Points
|
||||
from workouts.models import Workout
|
||||
from custom_user.models import CustomUser
|
||||
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Add additional test data"""
|
||||
|
||||
# Show this when the user types help
|
||||
help = "Adds test data"
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Actual Commandline executed function when manage.py command is called"""
|
||||
test_users = [
|
||||
{
|
||||
"email": "user1@admin.local",
|
||||
"password": "password",
|
||||
"first_name": "Charlotte",
|
||||
"last_name": "Doe",
|
||||
"is_superuser": True,
|
||||
"is_staff": True,
|
||||
"strava_athlete_id": 123456789,
|
||||
},
|
||||
{
|
||||
"email": "user2@admin.local",
|
||||
"password": "password",
|
||||
"first_name": "Tom",
|
||||
"last_name": "Smith-Bloggs",
|
||||
"is_superuser": True,
|
||||
"is_staff": True,
|
||||
},
|
||||
{
|
||||
"email": "user3@admin.local",
|
||||
"password": "password",
|
||||
"first_name": "User",
|
||||
"last_name": "von der Leyen",
|
||||
"is_superuser": False,
|
||||
"is_staff": False,
|
||||
},
|
||||
]
|
||||
|
||||
user_obj_dict = {}
|
||||
for user_i in test_users:
|
||||
user_obj = CustomUser.objects.create_user(**user_i)
|
||||
user_obj.set_password(user_i["password"])
|
||||
user_obj.save()
|
||||
|
||||
user_obj_dict[user_i["email"]] = user_obj
|
||||
|
||||
|
||||
|
||||
test_competitions = [
|
||||
{
|
||||
"owner": CustomUser.objects.get(email="user1@admin.local"),
|
||||
"name": "WHO Competition",
|
||||
"join_code": "WHOComp",
|
||||
"start_date": "2025-05-01",
|
||||
"end_date": "2025-12-31",
|
||||
"has_teams": True,
|
||||
"teams": [
|
||||
{
|
||||
"name": "Team 1",
|
||||
"members": [
|
||||
"user1@admin.local",
|
||||
"user2@admin.local",
|
||||
]
|
||||
}
|
||||
],
|
||||
"goals": [
|
||||
{
|
||||
"name": "Move",
|
||||
"metric": "kcal",
|
||||
"period": "week",
|
||||
"goal": 600,
|
||||
"max_per_day": 1_200,
|
||||
"max_per_week": 6_000, # 5x the daily limit
|
||||
},
|
||||
{
|
||||
"name": "Exercise",
|
||||
"metric": "min",
|
||||
"period": "day",
|
||||
"goal": 30,
|
||||
"max_per_day": 60,
|
||||
"max_per_week": 300, # 5x the daily limit
|
||||
},
|
||||
],
|
||||
"awards": [
|
||||
{
|
||||
"name": "10 Workouts (Bronze)",
|
||||
"sport": "GROUP_ANY",
|
||||
"threshold": 10,
|
||||
"period": "end",
|
||||
"reward_points": 250,
|
||||
},
|
||||
{
|
||||
"name": "25 Workouts (Silver)",
|
||||
"sport": "GROUP_ANY",
|
||||
"threshold": 25,
|
||||
"period": "end",
|
||||
"reward_points": 500,
|
||||
},
|
||||
{
|
||||
"name": "50 Workouts (Gold)",
|
||||
"sport": "GROUP_ANY",
|
||||
"threshold": 25,
|
||||
"period": "end",
|
||||
"reward_points": 1_000,
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
"owner": CustomUser.objects.get(email="user2@admin.local"),
|
||||
"name": "100k 1k Competition",
|
||||
"join_code": "100k1k",
|
||||
"start_date": "2025-01-01",
|
||||
"end_date": "2025-12-31",
|
||||
"teams": [
|
||||
{
|
||||
"name": "Team 1",
|
||||
"members": [
|
||||
"user3@admin.local",
|
||||
"user2@admin.local",
|
||||
]
|
||||
}
|
||||
],
|
||||
"goals": [
|
||||
{
|
||||
"name": "Distance",
|
||||
"metric": "km",
|
||||
"period": "competition",
|
||||
"goal": 1_000,
|
||||
"min_per_workout": 5,
|
||||
},
|
||||
{
|
||||
"name": "Effort",
|
||||
"metric": "kj",
|
||||
"period": "month",
|
||||
"goal": 1_000,
|
||||
},
|
||||
{
|
||||
"name": "Workouts",
|
||||
"metric": "num",
|
||||
"period": "week",
|
||||
"goal": 3,
|
||||
"max_per_day": 2,
|
||||
"max_per_week": 6,
|
||||
},
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
for competitions_i in test_competitions:
|
||||
goals = competitions_i.pop("goals", [])
|
||||
awards = competitions_i.pop("awards", [])
|
||||
teams = competitions_i.pop("teams", [])
|
||||
competitions_obj = Competition(**competitions_i)
|
||||
competitions_obj.save()
|
||||
|
||||
for goal_i in goals:
|
||||
goal_obj = ActivityGoal(competition=competitions_obj, **goal_i)
|
||||
goal_obj.save()
|
||||
|
||||
for award_i in awards:
|
||||
award_obj = Award(competition=competitions_obj, **award_i)
|
||||
award_obj.save()
|
||||
|
||||
for team_i in teams:
|
||||
team_i_name = team_i.pop("name")
|
||||
team_i_members = team_i.pop("members", [])
|
||||
team_obj = Team(competition=competitions_obj, name=team_i_name)
|
||||
team_obj.save()
|
||||
for user_i in team_i_members:
|
||||
user_i_obj = CustomUser.objects.get(email=user_i)
|
||||
user_i_obj.my_teams.add(team_obj)
|
||||
user_i_obj.my_competitions.add(competitions_obj)
|
||||
user_i_obj.save()
|
||||
|
||||
|
||||
test_workouts = []
|
||||
today = datetime.datetime.now()
|
||||
curr_datetime = today - datetime.timedelta(days=30*6) # 6 months ago
|
||||
while curr_datetime < today:
|
||||
today_entries = random.randint(0, len(test_users))
|
||||
user_entries = random.sample(test_users, k=today_entries)
|
||||
for user_i in user_entries:
|
||||
duration_i = random.randint(15, 75)
|
||||
sport_i = random.sample(['Run', 'Tennis', 'WeightTraining', 'Workout'], k=1)[0]
|
||||
|
||||
workout_i = {
|
||||
'user': user_i['email'],
|
||||
'sport_type': sport_i,
|
||||
'start_datetime': curr_datetime,
|
||||
'duration': timedelta(minutes=duration_i),
|
||||
'intensity_category': random.sample([1, 2, 3, 4], k=1)[0],
|
||||
'kcal': random.uniform(0.6, 1.3) * 10 * duration_i,
|
||||
'distance': duration_i / random.uniform(5, 8) if sport_i == 'Run' else None,
|
||||
'strava_id': random.randint(0, 999999999) if random.sample([True, False], k=1)[0] else None,
|
||||
}
|
||||
|
||||
#points_i = []
|
||||
#user_i_obj = CustomUser.objects.get(email=user_i['email'])
|
||||
#for competitions_i in user_i_obj.my_competitions.all():
|
||||
# if (curr_datetime.date() >= competitions_i.start_date) & (curr_datetime.date() <= competitions_i.end_date):
|
||||
# for goal_i in competitions_i.activitygoal_set.all():
|
||||
# points = duration_i * random.uniform(0.75, 2.5) / 25
|
||||
# points_i.append({
|
||||
# "goal": goal_i,
|
||||
# "points_raw": points,
|
||||
# "points_capped": points,
|
||||
# })
|
||||
|
||||
test_workouts.append({**workout_i}) #, 'points': points_i})
|
||||
curr_datetime += timedelta(minutes=random.uniform(2, 4) * 60 * 24)
|
||||
|
||||
|
||||
for workout_i in test_workouts:
|
||||
#points = workout_i.pop("points", [])
|
||||
user_email = workout_i.pop("user", None)
|
||||
user = CustomUser.objects.get(email=user_email)
|
||||
workout_obj = Workout(user=user, **workout_i)
|
||||
workout_obj.save()
|
||||
|
||||
#for point_i in points:
|
||||
# point_obj = Points(workout=workout_obj, **point_i)
|
||||
# point_obj.save()
|
||||
|
||||
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
from django.core.management import BaseCommand
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
class Command(BaseCommand):
|
||||
"""Add additional test data"""
|
||||
|
||||
# Show this when the user types help
|
||||
help = "Give user staff status"
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("email", nargs="?", type=str, help="Email of the user to promote to staff")
|
||||
|
||||
def handle(self, *args, **options):
|
||||
"""Actual Commandline executed function when manage.py command is called"""
|
||||
User = get_user_model()
|
||||
email = options.get("email")
|
||||
|
||||
if not email:
|
||||
email = input("Enter the email of the user to promote to staff: ")
|
||||
|
||||
try:
|
||||
user = User.objects.get(email=email)
|
||||
user.is_staff = True
|
||||
user.save()
|
||||
self.stdout.write(self.style.SUCCESS(f"User {email} is now staff."))
|
||||
except User.DoesNotExist:
|
||||
self.stdout.write(self.style.ERROR(f"No user found with email {email}"))
|
||||
41
src-backend/custom_user/management/commands/runcelerytask.py
Normal file
41
src-backend/custom_user/management/commands/runcelerytask.py
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
from django.core.management.base import BaseCommand
|
||||
from celery import current_app
|
||||
|
||||
class Command(BaseCommand):
|
||||
help = "Run a Celery task synchronously (default) or asynchronously (--async)."
|
||||
|
||||
def add_arguments(self, parser):
|
||||
parser.add_argument("task_name", help="Name of the Celery task")
|
||||
parser.add_argument("task_args", nargs="*", help="Task args and kwargs (key=value)")
|
||||
parser.add_argument(
|
||||
"--async", action="store_true", dest="async_mode",
|
||||
help="Run task asynchronously via Celery worker"
|
||||
)
|
||||
|
||||
def handle(self, *args, **options):
|
||||
task_name = options["task_name"]
|
||||
task = current_app.tasks.get(task_name)
|
||||
|
||||
if not task:
|
||||
self.stderr.write(self.style.ERROR(f"Task '{task_name}' not found"))
|
||||
return
|
||||
|
||||
# Parse args/kwargs
|
||||
positional, keyword = [], {}
|
||||
for arg in options["task_args"]:
|
||||
if "=" in arg:
|
||||
k, v = arg.split("=", 1)
|
||||
keyword[k] = v
|
||||
else:
|
||||
positional.append(arg)
|
||||
|
||||
if options["async_mode"]:
|
||||
result = task.delay(*positional, **keyword)
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"Task {task_name} dispatched asynchronously with id {result.id}"
|
||||
))
|
||||
else:
|
||||
result = task.apply(args=positional, kwargs=keyword)
|
||||
self.stdout.write(self.style.SUCCESS(
|
||||
f"Task {task_name} finished synchronously with result: {result.get()}"
|
||||
))
|
||||
215
src-backend/custom_user/models.py
Normal file
215
src-backend/custom_user/models.py
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
import requests
|
||||
import qrcode, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from django.db import models
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from django.contrib.auth.base_user import BaseUserManager
|
||||
from django.contrib.auth.models import AbstractBaseUser, PermissionsMixin
|
||||
from django.core.validators import MinValueValidator, MaxValueValidator
|
||||
from django.utils import timezone
|
||||
from django.conf import settings
|
||||
from django.db.models.signals import m2m_changed
|
||||
from django.dispatch import receiver
|
||||
|
||||
from competition.scorer import trigger_user_change
|
||||
from custom_user.emails.celery_emails import welcome_email
|
||||
|
||||
# Create your models here.
|
||||
GENDER_CHOICES = [
|
||||
('M', 'Male'),
|
||||
('F', 'Female'),
|
||||
('O', 'Other'),
|
||||
]
|
||||
|
||||
class CustomUserManager(BaseUserManager):
|
||||
"""
|
||||
Custom user model manager where email is the unique identifiers
|
||||
for authentication instead of usernames.
|
||||
"""
|
||||
|
||||
def create_user(self, email, password, **extra_fields):
|
||||
"""
|
||||
Create and save a user with the given email and password.
|
||||
"""
|
||||
if not email:
|
||||
raise ValueError(_("The Email must be set"))
|
||||
email = self.normalize_email(email)
|
||||
user = self.model(email=email, **extra_fields)
|
||||
user.set_password(password)
|
||||
user.save()
|
||||
return user
|
||||
|
||||
def create_superuser(self, email, password, **extra_fields):
|
||||
"""
|
||||
Create and save a SuperUser with the given email and password.
|
||||
"""
|
||||
extra_fields.setdefault("is_staff", True)
|
||||
extra_fields.setdefault("is_superuser", True)
|
||||
# extra_fields.setdefault("is_active", True)
|
||||
|
||||
if extra_fields.get("is_staff") is not True:
|
||||
raise ValueError(_("Superuser must have is_staff=True."))
|
||||
if extra_fields.get("is_superuser") is not True:
|
||||
raise ValueError(_("Superuser must have is_superuser=True."))
|
||||
return self.create_user(email, password, **extra_fields)
|
||||
|
||||
|
||||
class CustomUser(AbstractBaseUser, PermissionsMixin):
|
||||
"""Custom User model - needed to use email as login and a few more additional fields"""
|
||||
|
||||
email = models.EmailField(_("email address"), unique=True)
|
||||
first_name = models.CharField(max_length=30, null=False, blank=False)
|
||||
last_name = models.CharField(max_length=40, null=True, blank=True)
|
||||
gender = models.CharField(max_length=1, null=True, blank=True, choices=GENDER_CHOICES)
|
||||
|
||||
username = models.CharField(max_length=40, null=True, blank=True)
|
||||
|
||||
my_competitions = models.ManyToManyField('competition.Competition', blank=True, related_name='user')
|
||||
my_teams = models.ManyToManyField('competition.Team', blank=True, related_name='user')
|
||||
|
||||
# personal 7 day goals
|
||||
goal_active_days = models.IntegerField(null=True, blank=True, default=3)
|
||||
goal_workout_minutes = models.IntegerField(null=True, blank=True, default=150)
|
||||
goal_distance = models.IntegerField(null=True, blank=True, default=None)
|
||||
|
||||
# personal scaling factors
|
||||
scaling_kcal = models.DecimalField(null=False, blank=False, default=1, max_digits=8, decimal_places=4, validators=[
|
||||
MinValueValidator(Decimal('0.6666')),
|
||||
MaxValueValidator(Decimal('1.3333'))
|
||||
]
|
||||
)
|
||||
scaling_distance = models.DecimalField(null=False, blank=False, default=1, max_digits=8, decimal_places=4, validators=[
|
||||
MinValueValidator(Decimal('0.6666')),
|
||||
MaxValueValidator(Decimal('1.3333'))
|
||||
]
|
||||
)
|
||||
|
||||
# has_paid = models.BooleanField(default=False)
|
||||
is_verified = models.BooleanField(default=False)
|
||||
|
||||
email_mid_week = models.BooleanField(default=False)
|
||||
|
||||
strava_athlete_id = models.IntegerField(null=True, blank=True)
|
||||
strava_allow_follow = models.BooleanField(default=True)
|
||||
strava_refresh_token = models.CharField(max_length=40, null=True, blank=True)
|
||||
strava_last_synced_at = models.DateTimeField(null=True, blank=True)
|
||||
|
||||
is_staff = models.BooleanField(default=False)
|
||||
is_active = models.BooleanField(default=True)
|
||||
date_joined = models.DateTimeField(default=timezone.now)
|
||||
|
||||
USERNAME_FIELD = "email"
|
||||
REQUIRED_FIELDS = ["first_name", "last_name"]
|
||||
|
||||
objects = CustomUserManager()
|
||||
|
||||
class Meta:
|
||||
verbose_name = "User"
|
||||
verbose_name_plural = "Users"
|
||||
|
||||
def __str__(self):
|
||||
return self.email
|
||||
|
||||
|
||||
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 """
|
||||
if self.username is None or self.username == "":
|
||||
if self.first_name is None or self.first_name == "":
|
||||
self.username = self.email.split("@")[0]
|
||||
elif self.last_name is None or self.last_name == "":
|
||||
self.username = self.first_name
|
||||
else:
|
||||
self.username = f'{self.first_name} {".".join([i[0] for i in self.last_name.replace("-"," ").split(" ") if len(i) >= 1])}.'
|
||||
|
||||
is_create = self.pk is None
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
if is_create:
|
||||
eta = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=60 * 5)
|
||||
welcome_email.apply_async(args=[self.pk], eta=eta)
|
||||
|
||||
changed = self.get_changed_fields()
|
||||
trigger_user_change(
|
||||
instance=self,
|
||||
new=is_create,
|
||||
changes=changed
|
||||
)
|
||||
self._original = self._dict() # reset
|
||||
|
||||
|
||||
@receiver(m2m_changed, sender=CustomUser.my_competitions.through)
|
||||
def my_competitions_changed_handler(sender, instance, action, pk_set, **kwargs):
|
||||
if 'post' in action:
|
||||
if isinstance(instance, CustomUser):
|
||||
if 'add' in action:
|
||||
# instance user obj / pk_set comp id to add
|
||||
trigger_user_change(instance=instance, new=False, changes={'my_competitions': (None, list(pk_set))})
|
||||
elif 'remove' in action or 'clear' in action:
|
||||
# instance user obj / pk_set comp id to remove
|
||||
trigger_user_change(instance=instance, new=False, changes={'my_competitions': (list(pk_set), None)})
|
||||
else: # is instance of Competition
|
||||
for user_id in list(pk_set):
|
||||
user_obj = CustomUser.objects.get(pk=user_id)
|
||||
if 'add' in action:
|
||||
# instance competition obj / pk_set user id to add
|
||||
trigger_user_change(instance=user_obj, new=False, changes={'my_competitions': (None, [instance.pk])})
|
||||
elif 'remove' in action or 'clear' in action:
|
||||
# instance competition obj / pk_set user id to remove
|
||||
trigger_user_change(instance=user_obj, new=False, changes={'my_competitions': ([instance.pk], None)})
|
||||
|
||||
|
||||
|
||||
def get_strava_auth_url(user_id):
|
||||
""" Generate the initial auth url the user clicks, which will re-direct back to this page providing the code."""
|
||||
client_id = settings.STRAVA_CLIENT_ID
|
||||
redirect_url = f"{settings.MAIN_HOST}/strava/return/?user_id={user_id}"
|
||||
return f"https://www.strava.com/oauth/authorize?client_id={client_id}&response_type=code&approval_prompt=force&scope=profile:read_all,activity:read_all&redirect_uri={redirect_url}"
|
||||
|
||||
|
||||
def make_url_qr_code(url, path):
|
||||
qr = qrcode.QRCode(
|
||||
version=1,
|
||||
error_correction=qrcode.constants.ERROR_CORRECT_L,
|
||||
box_size=10,
|
||||
border=4,
|
||||
)
|
||||
qr.add_data(url)
|
||||
qr.make(fit=True)
|
||||
|
||||
img = qr.make_image(fill_color="black", back_color="white")
|
||||
img.save(path)
|
||||
|
||||
|
||||
|
||||
class RecalcRequest(models.Model):
|
||||
""" Recalc Request model to track which point caps need to be updated """
|
||||
|
||||
user = models.ForeignKey(CustomUser, on_delete=models.CASCADE, null=False, blank=False)
|
||||
goal = models.ForeignKey('competition.ActivityGoal', on_delete=models.CASCADE, null=False, blank=False)
|
||||
start_datetime = models.DateTimeField(null=False, blank=False)
|
||||
done = models.BooleanField(default=False, null=False, blank=False)
|
||||
|
||||
def __str__(self):
|
||||
return f'{self.goal} - {self.start_datetime}'
|
||||
177
src-backend/custom_user/point_recalc.py
Normal file
177
src-backend/custom_user/point_recalc.py
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import datetime
|
||||
|
||||
from django.db.models import Min
|
||||
|
||||
from django.core.cache import cache
|
||||
from workout_challenge.celery import app, is_task_already_executing
|
||||
from django.apps import apps
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
|
||||
def trigger_recalc_points():
|
||||
last_recalc = cache.get('last_recalc_points', None)
|
||||
|
||||
if last_recalc is None or last_recalc < datetime.datetime.now() - datetime.timedelta(seconds=30):
|
||||
cache.set('last_recalc_points', datetime.datetime.now(), 60 * 10)
|
||||
eta = datetime.datetime.now(datetime.timezone.utc) + datetime.timedelta(seconds=10)
|
||||
recalc_points.apply_async(eta=eta)
|
||||
else:
|
||||
print('Recalc points task skipped because it was triggered less than 30 seconds ago')
|
||||
|
||||
|
||||
@app.task(bind=True, time_limit=60 * 30, max_retries=3) # 30 min time limit
|
||||
def recalc_points(self):
|
||||
if is_task_already_executing('recalc_points'):
|
||||
print('Recalc points task skipped because it is already running')
|
||||
return 'Skipped because it is already running.'
|
||||
|
||||
print('Recalculating points...')
|
||||
|
||||
ActivityGoal = apps.get_model('competition', 'ActivityGoal')
|
||||
Points = apps.get_model('competition', 'Points')
|
||||
RecalcRequest = apps.get_model('custom_user', 'RecalcRequest')
|
||||
|
||||
all_tasks = RecalcRequest.objects.filter(done=False)
|
||||
grouped_tasks = all_tasks.values('user', 'goal').annotate(start_datetime=Min('start_datetime'))
|
||||
for task_group in grouped_tasks:
|
||||
points_lst = Points.objects.filter(goal=task_group['goal'], workout__user=task_group['user'], workout__start_datetime__gte=task_group['start_datetime']).order_by('workout__start_datetime')
|
||||
|
||||
goal = ActivityGoal.objects.get(pk=task_group['goal'])
|
||||
|
||||
scorer = Scorer()
|
||||
scorer.set_goal(goal)
|
||||
|
||||
for points in points_lst:
|
||||
earned_points = scorer.calculate_points(points)
|
||||
setattr(points, 'points_capped', earned_points)
|
||||
points.save()
|
||||
|
||||
all_tasks.delete()
|
||||
print('All points recalculated.')
|
||||
return [{k: str(v) for k, v in i.items()} for i in grouped_tasks]
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class Scorer:
|
||||
def __init__(self):
|
||||
self.memory_today = None
|
||||
self.memory_today_points_raw = 0
|
||||
self.memory_today_points_capped = 0
|
||||
self.memory_this_week = None
|
||||
self.memory_week_points_raw = 0
|
||||
self.memory_week_points_capped = 0
|
||||
|
||||
def set_goal(self, goal):
|
||||
self.goal = goal
|
||||
self.floor_workout = 0 if goal.min_per_workout is None else goal.min_per_workout / goal.goal * 100
|
||||
self.cap_workout = None if goal.max_per_workout is None else goal.max_per_workout / goal.goal * 100
|
||||
self.floor_day = 0 if goal.min_per_day is None else goal.min_per_day / goal.goal * 100
|
||||
self.cap_day = None if goal.max_per_day is None else goal.max_per_day / goal.goal * 100
|
||||
self.floor_week = 0 if goal.min_per_week is None else goal.min_per_week / goal.goal * 100
|
||||
self.cap_week = None if goal.max_per_week is None else goal.max_per_week / goal.goal * 100
|
||||
|
||||
def calculate_points(self, points):
|
||||
# potentially reset the memory if new day / week
|
||||
if points.workout.start_datetime.date() != self.memory_today:
|
||||
self.memory_today = points.workout.start_datetime.date()
|
||||
self.memory_today_points_raw = 0
|
||||
self.memory_today_points_capped = 0
|
||||
if points.workout.start_datetime.isocalendar()[1] != self.memory_this_week:
|
||||
self.memory_this_week = points.workout.start_datetime.isocalendar()[1]
|
||||
self.memory_week_points_raw = 0
|
||||
self.memory_week_points_capped = 0
|
||||
|
||||
earned_points = points.points_raw
|
||||
self.memory_today_points_raw += earned_points
|
||||
self.memory_week_points_raw += earned_points
|
||||
earned_points = points.points_raw
|
||||
|
||||
# workout floor
|
||||
earned_points = max(earned_points - self.floor_workout, 0)
|
||||
|
||||
# workout cap
|
||||
if self.cap_workout is not None:
|
||||
adjusted_cap = self.cap_workout - self.floor_workout
|
||||
earned_points = min(earned_points, adjusted_cap)
|
||||
|
||||
# day floor
|
||||
earned_points = max(min(earned_points, self.memory_today_points_raw - self.floor_day), 0)
|
||||
|
||||
# day cap
|
||||
if self.cap_day is not None:
|
||||
max_points_to_earn_today = self.cap_day - self.floor_day
|
||||
earned_points = max(min(earned_points, max_points_to_earn_today - self.memory_today_points_capped),0)
|
||||
|
||||
# week floor
|
||||
earned_points = max(min(earned_points, self.memory_week_points_raw - self.floor_week), 0)
|
||||
|
||||
# week cap
|
||||
if self.cap_week is not None:
|
||||
max_points_to_earn_week = self.cap_week - self.floor_week
|
||||
earned_points = max(min(earned_points, max_points_to_earn_week - self.memory_week_points_capped),0)
|
||||
|
||||
|
||||
self.memory_today_points_capped += earned_points
|
||||
self.memory_week_points_capped += earned_points
|
||||
return earned_points
|
||||
|
||||
|
||||
class DummyObject:
|
||||
def __init__(self, **kwargs):
|
||||
self.min_per_workout = None
|
||||
self.max_per_workout = None
|
||||
self.min_per_day = None
|
||||
self.max_per_day = None
|
||||
self.min_per_week = None
|
||||
self.max_per_week = None
|
||||
for key, value in kwargs.items():
|
||||
setattr(self, key, value)
|
||||
|
||||
|
||||
def test_scorer():
|
||||
|
||||
for goal_kwargs, points, expected_result in (
|
||||
({'goal': 100, 'min_per_workout': 10}, [10], 0),
|
||||
({'goal': 100, 'min_per_workout': 10}, [20], 10),
|
||||
({'goal': 100, 'max_per_workout': 30}, [30], 30),
|
||||
({'goal': 100, 'max_per_workout': 30}, [40], 30),
|
||||
({'goal': 100, 'min_per_workout': 10, 'max_per_workout': 30}, [40], 20),
|
||||
({'goal': 100, 'min_per_day': 10}, [10], 0),
|
||||
({'goal': 100, 'min_per_day': 10}, [20], 10),
|
||||
({'goal': 100, 'min_per_day': 10}, [8, 8], 6),
|
||||
({'goal': 100, 'max_per_day': 30}, [20], 20),
|
||||
({'goal': 100, 'max_per_day': 30}, [20, 20], 30),
|
||||
({'goal': 100, 'min_per_day': 10, 'max_per_day': 30}, [8, 12, 8, 8, 14], 20),
|
||||
({'goal': 100, 'min_per_week': 10}, [10], 0),
|
||||
({'goal': 100, 'min_per_week': 10}, [20], 10),
|
||||
({'goal': 100, 'max_per_week': 30}, [20], 20),
|
||||
({'goal': 100, 'max_per_week': 30}, [20, 20], 30),
|
||||
({'goal': 100, 'min_per_week': 10, 'max_per_week': 30}, [8, 12, 8, 8, 14], 20),
|
||||
({'goal': 100, 'min_per_workout': 10, 'min_per_day': 20}, [5, 20, 5, 20], 15),
|
||||
({'goal': 100, 'min_per_workout': 20, 'min_per_day': 10}, [5, 30, 30], 20),
|
||||
({'goal': 100, 'max_per_workout': 20, 'max_per_day': 30}, [20, 25, 25, 25], 30),
|
||||
({'goal': 100, 'max_per_workout': 30, 'max_per_day': 20}, [20, 25, 25, 25], 20),
|
||||
({'goal': 100, 'min_per_workout': 10, 'max_per_day': 15}, [5, 5, 5, 5], 0),
|
||||
({'goal': 100, 'min_per_workout': 10, 'max_per_day': 30}, [15, 35, 5, 15], 30),
|
||||
):
|
||||
workout = DummyObject(start_datetime=datetime.datetime.fromisoformat('2023-01-01T00:00:00'))
|
||||
goal = DummyObject(**goal_kwargs)
|
||||
scorer = Scorer()
|
||||
scorer.set_goal(goal)
|
||||
|
||||
earned_points = 0
|
||||
for point in points:
|
||||
point_obj = DummyObject(points_raw=point, workout = workout)
|
||||
earned_points += scorer.calculate_points(point_obj)
|
||||
|
||||
assert earned_points == expected_result, f'Expected {expected_result}, got {earned_points} for goal {goal_kwargs}'
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
test_scorer()
|
||||
|
||||
118
src-backend/custom_user/serializers.py
Normal file
118
src-backend/custom_user/serializers.py
Normal file
|
|
@ -0,0 +1,118 @@
|
|||
from rest_framework import serializers
|
||||
from django.conf import settings
|
||||
from django.contrib.auth.tokens import default_token_generator
|
||||
from django.utils.http import urlsafe_base64_encode, urlsafe_base64_decode
|
||||
from django.utils.encoding import force_bytes
|
||||
from django.template.loader import render_to_string
|
||||
|
||||
from .models import CustomUser
|
||||
from .emails.multipurpose import send_email
|
||||
|
||||
|
||||
class CustomUserSerializer(serializers.ModelSerializer):
|
||||
my = serializers.SerializerMethodField()
|
||||
|
||||
class Meta:
|
||||
model = CustomUser
|
||||
fields = ['id', 'my', 'email', 'first_name', 'last_name', 'gender', 'username', 'password', 'is_verified', 'email_mid_week', 'strava_athlete_id', 'strava_allow_follow', 'strava_last_synced_at', 'my_competitions', 'my_teams', 'goal_active_days', 'goal_workout_minutes', 'goal_distance', 'scaling_kcal', 'scaling_distance']
|
||||
read_only_fields = ['is_verified', 'strava_athlete_id', 'strava_last_synced_at']
|
||||
extra_kwargs = {
|
||||
'password': {'write_only': True},
|
||||
}
|
||||
|
||||
def get_my(self, obj):
|
||||
user = self.context['request'].user
|
||||
return obj.pk == user.pk
|
||||
|
||||
def create(self, validated_data):
|
||||
user = CustomUser.objects.create_user(
|
||||
email=validated_data.get('email'),
|
||||
first_name=validated_data.get('first_name'),
|
||||
last_name=validated_data.get('last_name', None),
|
||||
password=validated_data.get('password'),
|
||||
gender=validated_data.get('gender', None),
|
||||
)
|
||||
return user
|
||||
|
||||
def to_representation(self, instance):
|
||||
rep = super().to_representation(instance)
|
||||
user = self.context['request'].user
|
||||
|
||||
# Omit 'secret' fields of other users that this user is not allowed to see
|
||||
if instance.pk != user.pk:
|
||||
rep.pop('email', None)
|
||||
rep.pop('first_name', None)
|
||||
rep.pop('last_name', None)
|
||||
rep.pop('gender', None)
|
||||
rep.pop('password', None)
|
||||
rep.pop('strava_last_synced_at', None)
|
||||
|
||||
if not rep['strava_allow_follow']:
|
||||
rep.pop('strava_athlete_id', None)
|
||||
|
||||
return rep
|
||||
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
# If instance exists, it's an update (PUT/PATCH), make fields optional
|
||||
if self.instance:
|
||||
self.fields['email'].required = False
|
||||
self.fields['password'].required = False
|
||||
self.fields['first_name'].required = False
|
||||
self.fields['last_name'].required = False
|
||||
|
||||
|
||||
class PasswordResetSerializer(serializers.Serializer):
|
||||
email = serializers.EmailField()
|
||||
|
||||
def validate_email(self, value):
|
||||
if not CustomUser.objects.filter(email=value).exists():
|
||||
# To avoid leaking info
|
||||
return value
|
||||
return value
|
||||
|
||||
def save(self, request):
|
||||
email = self.validated_data['email']
|
||||
users = CustomUser.objects.filter(email=email)
|
||||
for user in users:
|
||||
uid = urlsafe_base64_encode(force_bytes(user.pk))
|
||||
token = default_token_generator.make_token(user)
|
||||
reset_url = f"{settings.MAIN_HOST}/password/reset/{uid}/{token}/"
|
||||
|
||||
email_subject = "Workout Challenge - Reset Your Password"
|
||||
email_body = render_to_string(
|
||||
"email_password_reset.html",
|
||||
{
|
||||
'first_name': user.first_name,
|
||||
'MAIN_HOST': settings.MAIN_HOST,
|
||||
'RESET_URL': reset_url,
|
||||
'EMAIL_REPLY_TO': settings.EMAIL_REPLY_TO,
|
||||
}
|
||||
)
|
||||
|
||||
send_email(subject=email_subject, body=email_body, to_email=user.email)
|
||||
|
||||
|
||||
|
||||
class PasswordResetConfirmSerializer(serializers.Serializer):
|
||||
uid = serializers.CharField()
|
||||
token = serializers.CharField()
|
||||
new_password = serializers.CharField(write_only=True)
|
||||
|
||||
def validate(self, attrs):
|
||||
try:
|
||||
uid = urlsafe_base64_decode(attrs['uid']).decode()
|
||||
self.user = CustomUser.objects.get(pk=uid)
|
||||
except (CustomUser.DoesNotExist, ValueError):
|
||||
raise serializers.ValidationError("Invalid user.")
|
||||
|
||||
if not default_token_generator.check_token(self.user, attrs['token']):
|
||||
raise serializers.ValidationError("Invalid or expired token.")
|
||||
|
||||
return attrs
|
||||
|
||||
def save(self):
|
||||
self.user.set_password(self.validated_data['new_password'])
|
||||
self.user.save()
|
||||
194
src-backend/custom_user/strava.py
Normal file
194
src-backend/custom_user/strava.py
Normal file
|
|
@ -0,0 +1,194 @@
|
|||
import requests
|
||||
import time, datetime
|
||||
|
||||
from django.core.cache import cache
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from django.db import IntegrityError
|
||||
from workout_challenge.celery import app, is_task_already_executing
|
||||
from django.db.models import Q
|
||||
|
||||
from workouts.models import Workout
|
||||
from .api_rate_limiter import strava_api_monitor, RateLimitExceeded # Import to trigger initialization
|
||||
|
||||
|
||||
def _seconds_until_next_interval():
|
||||
current_time = time.localtime()
|
||||
minutes = current_time.tm_min
|
||||
seconds = current_time.tm_sec
|
||||
|
||||
# Calculate the next interval (15, 30, 45, or 0 of the next hour)
|
||||
next_interval = (minutes // 15 + 1) * 15
|
||||
if next_interval == 60: # Handle the case where it rolls over to the next hour
|
||||
next_interval = 0
|
||||
|
||||
# Calculate the seconds until the next interval
|
||||
seconds_until = (next_interval - minutes) * 60 - seconds
|
||||
if next_interval == 0: # Adjust for the next hour
|
||||
seconds_until += 3600
|
||||
|
||||
return seconds_until
|
||||
|
||||
|
||||
@app.task(bind=True, time_limit=60 * 60 * 3, max_retries=10) # 3 hour time limit
|
||||
def daily_strava_sync(self, refresh_all=False):
|
||||
if is_task_already_executing('daily_strava_sync'):
|
||||
return 'Task already executing. Skipping.'
|
||||
|
||||
CustomUser = get_user_model()
|
||||
user_lst = CustomUser.objects.filter(
|
||||
strava_refresh_token__isnull=False,
|
||||
is_active=True
|
||||
)
|
||||
if refresh_all is False:
|
||||
user_lst = user_lst.filter(
|
||||
Q(strava_last_synced_at__lt=timezone.now() - datetime.timedelta(hours=6)) |
|
||||
Q(strava_last_synced_at__isnull=True)
|
||||
)
|
||||
user_lst = user_lst.order_by('strava_last_synced_at', 'pk')
|
||||
|
||||
user_lst_names = [{'pk': i.pk, 'username': i.username, 'email': i.email} for i in user_lst]
|
||||
print(f'Syncing Strava for {len(user_lst)} users: {user_lst_names}')
|
||||
|
||||
for user in user_lst:
|
||||
try:
|
||||
sync_strava(user__id=user.id)
|
||||
except RateLimitExceeded as exc:
|
||||
sleep_time = _seconds_until_next_interval() + 60
|
||||
print(f'Strava sync rate limit exceeded - sleeping for {sleep_time // 60 } mins')
|
||||
raise self.retry(exc=exc, countdown=sleep_time) # retry in next Strava 15min api period
|
||||
except Exception as exc:
|
||||
print(f'Strava sync failed for user {user.email} - {exc}')
|
||||
|
||||
print('Finished syncing Strava.')
|
||||
return user_lst_names
|
||||
|
||||
|
||||
|
||||
@app.task(bind=True)
|
||||
def sync_strava(self, user__id, start_datetime=None):
|
||||
access_token = cache.get(f"strava_access_token_{user__id}")
|
||||
CustomUser = get_user_model()
|
||||
user = CustomUser.objects.get(id=user__id)
|
||||
|
||||
all_existing_strava_activities = set(Workout.objects.all().values_list('strava_id', flat=True))
|
||||
|
||||
cnt_new_strava_activities = 0
|
||||
cnt_updated_strava_activities = 0
|
||||
|
||||
if strava_api_monitor.ok_workout_requests() is False:
|
||||
raise RateLimitExceeded("No Strava Workout API requests allowed anymore to keep enough balance for user linkage")
|
||||
|
||||
# refresh access token if expired
|
||||
if access_token is None:
|
||||
refresh_token = user.strava_refresh_token
|
||||
client_id = settings.STRAVA_CLIENT_ID
|
||||
client_secret = settings.STRAVA_CLIENT_SECRET
|
||||
|
||||
response = requests.post(
|
||||
url='https://www.strava.com/oauth/token',
|
||||
data={
|
||||
'client_id': client_id,
|
||||
'client_secret': client_secret,
|
||||
'grant_type': 'refresh_token',
|
||||
'refresh_token': refresh_token,
|
||||
}
|
||||
)
|
||||
strava_api_monitor.log_request(response)
|
||||
response.raise_for_status()
|
||||
|
||||
strava_tokens = response.json()
|
||||
access_token = strava_tokens.get('access_token', None)
|
||||
cache.set(f"strava_access_token_{user__id}", access_token, int(strava_tokens.get('expires_in', 21600)) - 60)
|
||||
|
||||
|
||||
# get activities
|
||||
page = 1
|
||||
per_page = 200
|
||||
while True:
|
||||
if strava_api_monitor.ok_workout_requests() is False:
|
||||
raise RateLimitExceeded("No Strava Workout API requests allowed anymore to keep enough balance for user linkage")
|
||||
|
||||
response = requests.get(
|
||||
url='https://www.strava.com/api/v3/athlete/activities',
|
||||
headers={
|
||||
'Authorization': f'Bearer {access_token}',
|
||||
},
|
||||
params={
|
||||
'after': None if start_datetime is None else int(start_datetime.timestamp()),
|
||||
'page': page,
|
||||
'per_page': per_page,
|
||||
}
|
||||
)
|
||||
strava_api_monitor.log_request(response)
|
||||
response.raise_for_status()
|
||||
activities = response.json()
|
||||
|
||||
for activity in activities:
|
||||
activity_id = activity.get('id')
|
||||
|
||||
props = {
|
||||
'user': user,
|
||||
'strava_id': activity_id,
|
||||
'sport_type': activity.get('sport_type'),
|
||||
'start_datetime': datetime.datetime.fromisoformat(activity.get('start_date')),
|
||||
'duration': datetime.timedelta(seconds=activity.get('moving_time')),
|
||||
'distance': None if activity.get('distance') == 0 else activity.get('distance') / 1_000,
|
||||
}
|
||||
|
||||
# if existing workout - update activity details
|
||||
if activity_id in all_existing_strava_activities:
|
||||
workout = Workout.objects.get(strava_id=activity_id)
|
||||
for key, value in props.items():
|
||||
setattr(workout, key, value)
|
||||
workout.save()
|
||||
cnt_updated_strava_activities += 1
|
||||
|
||||
# if a new workout - get activity details
|
||||
else:
|
||||
if strava_api_monitor.ok_workout_requests() is False:
|
||||
raise RateLimitExceeded("No Strava Workout API requests allowed anymore to keep enough balance for user linkage")
|
||||
|
||||
response = requests.get(
|
||||
url=f'https://www.strava.com/api/v3/activities/{activity_id}',
|
||||
headers={
|
||||
'Authorization': f'Bearer {access_token}',
|
||||
},
|
||||
)
|
||||
strava_api_monitor.log_request(response)
|
||||
response.raise_for_status()
|
||||
activity_details = response.json()
|
||||
|
||||
avg_heart_rate = activity_details.get('average_heartrate', 0)
|
||||
props['kcal'] = kcal = activity_details.get('calories', activity_details.get('kilojoules', 0) / 4.18)
|
||||
props['strava_intensity_avg_watts'] = avg_watt = activity_details.get('average_watts', 0)
|
||||
|
||||
# estimate intensity
|
||||
max_heart_rate = 180
|
||||
kcal_per_ten_minute = kcal / (max(activity.get('moving_time', 60 * 30), 60) / (60 * 10))
|
||||
if avg_heart_rate > max_heart_rate * 0.85 or kcal_per_ten_minute > 120 or avg_watt > 300:
|
||||
props['intensity_category'] = 4
|
||||
elif avg_heart_rate > max_heart_rate * 0.70 or kcal_per_ten_minute > 90 or avg_watt > 275:
|
||||
props['intensity_category'] = 3
|
||||
elif avg_heart_rate > max_heart_rate * 0.60 or kcal_per_ten_minute > 75 or avg_watt > 225:
|
||||
props['intensity_category'] = 2
|
||||
else:
|
||||
props['intensity_category'] = 1
|
||||
|
||||
Workout.objects.create(**props)
|
||||
cnt_new_strava_activities += 1
|
||||
|
||||
if len(activities) < per_page:
|
||||
break
|
||||
page += 1
|
||||
|
||||
strava_last_synced_at = timezone.now()
|
||||
if start_datetime is None:
|
||||
setattr(user, 'strava_last_synced_at', strava_last_synced_at)
|
||||
user.save()
|
||||
print(f'User {user__id} - fetched {cnt_new_strava_activities} new strava activities and updated {cnt_updated_strava_activities} existing strava activities')
|
||||
|
||||
return {'user': user__id, 'total_activities': (page - 1) * per_page + len(activities), 'new_activities': cnt_new_strava_activities, 'updated_activities': cnt_updated_strava_activities, 'sync_time': strava_last_synced_at}
|
||||
0
src-backend/custom_user/templates/__init__.py
Normal file
0
src-backend/custom_user/templates/__init__.py
Normal file
409
src-backend/custom_user/templates/email_competition_start.html
Normal file
409
src-backend/custom_user/templates/email_competition_start.html
Normal file
|
|
@ -0,0 +1,409 @@
|
|||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>
|
||||
</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mj-column-per-50 {
|
||||
width: 50% !important;
|
||||
max-width: 50%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.moz-text-html .mj-column-per-50 {
|
||||
width: 50% !important;
|
||||
max-width: 50%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
</style>
|
||||
<style type="text/css">
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="word-spacing:normal;background-color:#E1E8ED;">
|
||||
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;"> The "{{ competition.name }}" competition kicks off tomorrow! Give it a strong start, build momentum – your best is waiting! But first, here's how the goals work and how to earn points. </div>
|
||||
<div style="background-color:#E1E8ED;">
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0;font-size:0;mso-line-height-rule:exactly;"><v:image style="border:0;mso-position-horizontal:center;position:absolute;top:0;width:600px;z-index:-3;" src="{{ MAIN_HOST }}/running_email.jpg" xmlns:v="urn:schemas-microsoft-com:vml" /><![endif]-->
|
||||
<div style="margin:0 auto;max-width:600px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr style="vertical-align:top;">
|
||||
<td background="{{ MAIN_HOST }}/running_email.jpg" style="background:#075985 url('{{ MAIN_HOST }}/running_email.jpg') no-repeat center center / cover;background-position:center center;background-repeat:no-repeat;padding:0px;vertical-align:top;" height="200">
|
||||
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" style="width:600px;" width="600" ><tr><td style=""><![endif]-->
|
||||
<div class="mj-hero-content" style="margin:0px auto;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;margin:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;margin:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" class="no-invert" style="font-size:0px;padding:10px 25px;padding-top:45px;padding-bottom:10px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:30px;line-height:1.2;text-align:center;color:#ffffff;">Workout Challenge</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#075985" role="presentation" style="border:1px solid #fff;border-radius:24px;cursor:auto;mso-padding-alt:10px 25px;background:#075985;" valign="middle">
|
||||
<a href="{{ MAIN_HOST }}/login" style="display:inline-block;background:#075985;color:#ffffff;font-family:Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:24px;" target="_blank"> LOGIN </a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="white" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:white;background-color:white;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:white;background-color:white;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align:top;padding-bottom:0px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<p>Hi {{ first_name }},</p>
|
||||
<p>The "{{ competition.name }}" competition kicks off tomorrow!</p>
|
||||
<p>Give it a strong start, build momentum – your best is waiting!</p>
|
||||
<p>But first, here's how the goals work and how to earn points.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<p style="border-top:solid 2px #E1E8ED;font-size:1px;margin:0px auto;width:100%;">
|
||||
</p>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #E1E8ED;font-size:1px;margin:0px auto;width:550px;" role="presentation" width="550px" ><tr><td style="height:0;line-height:0;">
|
||||
</td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="white" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:white;background-color:white;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:white;background-color:white;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-top:5px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:300px;" ><![endif]-->
|
||||
<div class="mj-column-per-50 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align:top;padding-top:0px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:10px;padding-bottom:15px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<h3>How to earn activity points?</h3>
|
||||
<ul>
|
||||
<li>Each competition has its own activity goals.</li>
|
||||
<li>You earn <b>1 point for every 1%</b> progress toward a goal.</li>
|
||||
<li>For example, if the goal is 100 minutes of exercise and you work out 50 minutes, you earn 50 points.</li>
|
||||
<li><i>Note:</i> Some goals have <b>minimum or maximum limits</b>. Activities above/below these limits won’t earn you points and are marked with an asterisk (*).</li>
|
||||
<li>Hover over a goal to view its limits, or over the asterisk for more details.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:300px;" ><![endif]-->
|
||||
<div class="mj-column-per-50 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<h3>Activity Goals:</h3>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;word-break:break-word;"> {% for goal in goals %} <table cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="height:10px; font-size:0; line-height:0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color:#f9f9f9; border-radius:8px; padding:18px;">
|
||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="font-size:14px; line-height:16px; mso-line-height-rule:exactly; font-weight:bold; color: #6b7280;">{{ goal.name }}</td>
|
||||
<td align="right" style="font-size:14px; line-height:16px; mso-line-height-rule:exactly; color: #6b7280;">{{ goal.goal|floatformat:0 }} {{ goal.metric }} <span style="font-size:10px;">/ {{ goal.period }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" height="12" style="padding-top:8px; line-height:0; font-size:0;">
|
||||
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" arcsize="50%" strokecolor="#e5e5e5" strokeweight="1px" style="width:250%;height:12px;" fillcolor="#e5e5e5"><v:fill color="#e5e5e5" /></v:roundrect><![endif]-->
|
||||
<!--[if !mso]><!-- -->
|
||||
<div style="background:#e5e5e5; border-radius:4px; width:100%; height:12px;"></div>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr> {% if goal.min_per_workout > 0 or goal.max_per_workout > 0 or goal.min_per_day > 0 or goal.max_per_day > 0 or goal.min_per_week > 0 or goal.max_per_week > 0 %} <tr>
|
||||
<td style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#6b7280;padding-top: 7px;" colspan="2"><b>Limits:</b>
|
||||
<ul style="margin: 0px;"> {% if goal.min_per_workout > 0 %}<li>min <b>{{ goal.min_per_workout|floatformat:0 }} {{ goal.metric }}</b> <span style="font-size:10px">/ workout</span></li>{% endif %} {% if goal.max_per_workout > 0 %}<li>max <b>{{ goal.max_per_workout|floatformat:0 }} {{ goal.metric }}</b> <span style="font-size:10px">/ workout</span></li>{% endif %} {% if goal.min_per_day > 0 %}<li>min <b>{{ goal.min_per_day|floatformat:0 }} {{ goal.metric }}</b> <span style="font-size:10px">/ day</span></li>{% endif %} {% if goal.max_per_day > 0 %}<li>max <b>{{ goal.max_per_day|floatformat:0 }} {{ goal.metric }}</b> <span style="font-size:10px">/ day</span></li>{% endif %} {% if goal.min_per_week > 0 %}<li>min <b>{{ goal.min_per_week|floatformat:0 }} {{ goal.metric }}</b> <span style="font-size:10px">/ week</span></li>{% endif %} {% if goal.max_per_week > 0 %}<li>max <b>{{ goal.max_per_week|floatformat:0 }} {{ goal.metric }}</b> <span style="font-size:10px">/ week</span></li>{% endif %} </ul>
|
||||
</td>
|
||||
</tr> {% endif %}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table> {% endfor %} </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]--> {% if goal_equalizer_note %}
|
||||
<!-- Goal Equalizer -->
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#075985" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:#075985;background-color:#075985;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#075985;background-color:#075985;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:middle;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:10px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:18px;line-height:1.2;text-align:center;color:#ffffff;">Make it fair by enabling the Goal Equalizer!</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-right:100px;padding-bottom:20px;padding-left:100px;word-break:break-word;">
|
||||
<p style="border-top:solid 1px #ffffff;font-size:1px;margin:0px auto;width:100%;">
|
||||
</p>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 1px #ffffff;font-size:1px;margin:0px auto;width:400px;" role="presentation" width="400px" ><tr><td style="height:0;line-height:0;">
|
||||
</td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:25px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:11px;line-height:1.2;text-align:center;color:#e5e7eb;">Everyone has a unique <b>Basal Metabolic Rate (BMR)</b>, dependent on factors like age, gender, height, and weight. The default goals are calibrated for a 35y/o 1.8m tall man. To ensure a fair competition, login and personalise your goals using the "Goal Equalizer" right below your personal settings.</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]--> {% endif %}
|
||||
<!-- Final remarks -->
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="white" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:white;background-color:white;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:white;background-color:white;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody> {% if goal_equalizer_note is None %} <tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<p style="border-top:solid 2px #E1E8ED;font-size:1px;margin:0px auto;width:100%;">
|
||||
</p>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #E1E8ED;font-size:1px;margin:0px auto;width:550px;" role="presentation" width="550px" ><tr><td style="height:0;line-height:0;">
|
||||
</td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr> {% endif %} <tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:20px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">Login to see who else is participating, change your team, and adjust the goal equalizers:</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;padding-top:5px;padding-bottom:2px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#075985" role="presentation" style="border:none;border-radius:24px;cursor:auto;mso-padding-alt:10px 25px;background:#075985;" valign="middle">
|
||||
<a href="{{ MAIN_HOST }}/login" style="display:inline-block;background:#075985;color:#ffffff;font-family:Helvetica, Arial, sans-serif, Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:24px;" target="_blank"> LOGIN </a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:10px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<p>Enjoy the Competition.</p>
|
||||
<p>Good luck!</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
<!-- Footnote -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:middle;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:11px;line-height:1.2;text-align:center;color:#9a9ea6;"><span class="apple-link">Workout Challenge</span>
|
||||
<br> Any issues? <a href="mailto:{{ EMAIL_REPLY_TO }}">Send us an Email</a>. <br> See the <a href="https://github.com/vanalmsick/workout_challenge">Source Code</a>.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
146
src-backend/custom_user/templates/email_competition_start.mjml
Normal file
146
src-backend/custom_user/templates/email_competition_start.mjml
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
<mjml>
|
||||
<!-- Written with https://mjml.io/ -->
|
||||
<mj-head>
|
||||
<mj-preview>The "{{ competition.name }}" competition kicks off tomorrow! Give it a strong start, build momentum – your best is waiting! But first, here's how the goals work and how to earn points.</mj-preview>
|
||||
<mj-attributes>
|
||||
<mj-text line-height="1.2" />
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
<mj-body background-color="#E1E8ED">
|
||||
<mj-hero mode="fixed-height" height="200px" background-url="{{ MAIN_HOST }}/running_email.jpg" background-size="cover" background-repeat="no-repeat" background-color="#075985">
|
||||
<mj-text align="center" font-size="30px" color="#ffffff" padding-bottom="10px" padding-top="45px" css-class="no-invert">
|
||||
Workout Challenge
|
||||
</mj-text>
|
||||
<mj-button background-color="#075985" color="#fff" border-radius="24px" border="1px solid #fff" href="{{ MAIN_HOST }}/login" font-family="Helvetica, Arial, sans-serif" padding="10px 25px">
|
||||
LOGIN
|
||||
</mj-button>
|
||||
</mj-hero>
|
||||
<mj-section padding-top="0px" padding-bottom="0px" background-color="white">
|
||||
<mj-column padding-bottom="0px">
|
||||
<mj-text padding-bottom="0px">
|
||||
<p>Hi {{ first_name }},</p>
|
||||
<p>The "{{ competition.name }}" competition kicks off tomorrow!</p>
|
||||
<p>Give it a strong start, build momentum – your best is waiting!</p>
|
||||
<p>But first, here's how the goals work and how to earn points.</p>
|
||||
</mj-text>
|
||||
<mj-divider border-color="#E1E8ED" border-width="2px"></mj-divider>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section background-color="white" padding-top="5px">
|
||||
<mj-column padding-top="0px">
|
||||
<mj-text padding-top="10px" padding-bottom="15px">
|
||||
<h3>How to earn activity points?</h3>
|
||||
<ul>
|
||||
<li>Each competition has its own activity goals.</li>
|
||||
<li>You earn <b>1 point for every 1%</b> progress toward a goal.</li>
|
||||
<li>For example, if the goal is 100 minutes of exercise and you work out 50 minutes, you earn 50 points.</li>
|
||||
<li><i>Note:</i> Some goals have <b>minimum or maximum limits</b>. Activities above/below these limits won’t earn you points and are marked with an asterisk (*).</li>
|
||||
<li>Hover over a goal to view its limits, or over the asterisk for more details.</li>
|
||||
</ul>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
<mj-column>
|
||||
<mj-raw>
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<h3>Activity Goals:</h3>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;word-break:break-word;">
|
||||
|
||||
{% for goal in goals %}
|
||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="height:10px; font-size:0; line-height:0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color:#f9f9f9; border-radius:8px; padding:18px;">
|
||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="font-size:14px; line-height:16px; mso-line-height-rule:exactly; font-weight:bold; color: #6b7280;">{{ goal.name }}</td>
|
||||
<td align="right" style="font-size:14px; line-height:16px; mso-line-height-rule:exactly; color: #6b7280;">{{ goal.goal|floatformat:0 }} {{ goal.metric }} <span style="font-size:10px;">/ {{ goal.period }}</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" height="12" style="padding-top:8px; line-height:0; font-size:0;">
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" arcsize="50%" strokecolor="#e5e5e5" strokeweight="1px" style="width:250%;height:12px;" fillcolor="#e5e5e5">
|
||||
<v:fill color="#e5e5e5" />
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-- -->
|
||||
<div style="background:#e5e5e5; border-radius:4px; width:100%; height:12px;">
|
||||
</div>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
{% if goal.min_per_workout > 0 or goal.max_per_workout > 0 or goal.min_per_day > 0 or goal.max_per_day > 0 or goal.min_per_week > 0 or goal.max_per_week > 0 %}
|
||||
<tr>
|
||||
<td style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#6b7280;padding-top: 7px;" colspan="2"><b>Limits:</b>
|
||||
<ul style="margin: 0px;">
|
||||
{% if goal.min_per_workout > 0 %}<li>min <b>{{ goal.min_per_workout|floatformat:0 }} {{ goal.metric }}</b> <span style="font-size:10px">/ workout</span></li>{% endif %}
|
||||
{% if goal.max_per_workout > 0 %}<li>max <b>{{ goal.max_per_workout|floatformat:0 }} {{ goal.metric }}</b> <span style="font-size:10px">/ workout</span></li>{% endif %}
|
||||
{% if goal.min_per_day > 0 %}<li>min <b>{{ goal.min_per_day|floatformat:0 }} {{ goal.metric }}</b> <span style="font-size:10px">/ day</span></li>{% endif %}
|
||||
{% if goal.max_per_day > 0 %}<li>max <b>{{ goal.max_per_day|floatformat:0 }} {{ goal.metric }}</b> <span style="font-size:10px">/ day</span></li>{% endif %}
|
||||
{% if goal.min_per_week > 0 %}<li>min <b>{{ goal.min_per_week|floatformat:0 }} {{ goal.metric }}</b> <span style="font-size:10px">/ week</span></li>{% endif %}
|
||||
{% if goal.max_per_week > 0 %}<li>max <b>{{ goal.max_per_week|floatformat:0 }} {{ goal.metric }}</b> <span style="font-size:10px">/ week</span></li>{% endif %}
|
||||
</ul>
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endfor %}
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</mj-raw>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-raw> {% if goal_equalizer_note %} </mj-raw>
|
||||
<!-- Goal Equalizer -->
|
||||
<mj-section background-color="#075985" vertical-align="middle">
|
||||
<mj-column width="100%" vertical-align="middle">
|
||||
<mj-text align="center" color="#ffffff" font-size="18px" padding-bottom="10px" padding-top="20px">Make it fair by enabling the Goal Equalizer!</mj-text>
|
||||
<mj-divider border-color="#fff" border-style="solid" border-width="1px" padding-left="100px" padding-right="100px" padding-bottom="20px"></mj-divider>
|
||||
<mj-text align="center" color="#e5e7eb" font-size="11px" padding-bottom="25px" padding-top="0px">Everyone has a unique <b>Basal Metabolic Rate (BMR)</b>, dependent on factors like age, gender, height, and weight. The default goals are calibrated for a 35y/o 1.8m tall man. To ensure a fair competition, login and personalise your goals using the "Goal Equalizer" right below your personal settings.</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-raw> {% endif %} </mj-raw>
|
||||
|
||||
<!-- Final remarks -->
|
||||
<mj-section padding-top="0px" padding-bottom="0px" background-color="white">
|
||||
<mj-column>
|
||||
<mj-raw> {% if goal_equalizer_note is None %} </mj-raw>
|
||||
<mj-divider border-color="#E1E8ED" border-width="2px"></mj-divider>
|
||||
<mj-raw> {% endif %} </mj-raw>
|
||||
<mj-text padding-top="20px">Login to see who else is participating, change your team, and adjust the goal equalizers:</mj-text>
|
||||
<mj-button align="center" background-color="#075985" color="#ffffff" border-radius="24px" href="{{ MAIN_HOST }}/login" font-family="Helvetica, Arial, sans-serif, Helvetica, Arial, sans-serif" padding-bottom="2px" padding-top="5px">LOGIN</mj-button>
|
||||
<mj-text padding-bottom="10px" padding-top="0px">
|
||||
<p>Enjoy the Competition.</p>
|
||||
<p>Good luck!</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<!-- Footnote -->
|
||||
<mj-section full-width="full-width">
|
||||
<mj-column width="100%" vertical-align="middle">
|
||||
<mj-text align="center" color="#9a9ea6" font-size="11px" padding-bottom="0px" padding-top="0">
|
||||
<span class="apple-link">Workout Challenge</span>
|
||||
<br> Any issues? <a href="mailto:{{ EMAIL_REPLY_TO }}">Send us an Email</a>. <br> See the <a href="https://github.com/vanalmsick/workout_challenge">Source Code</a>.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
497
src-backend/custom_user/templates/email_leaderboard.html
Normal file
497
src-backend/custom_user/templates/email_leaderboard.html
Normal file
|
|
@ -0,0 +1,497 @@
|
|||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>
|
||||
</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mj-column-per-50 {
|
||||
width: 50% !important;
|
||||
max-width: 50%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.moz-text-html .mj-column-per-50 {
|
||||
width: 50% !important;
|
||||
max-width: 50%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
</style>
|
||||
<style type="text/css">
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="word-spacing:normal;background-color:#E1E8ED;">
|
||||
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;"> New week - fresh start! But first let's see where you're on the Leaderbaord for last week! </div>
|
||||
<div style="background-color:#E1E8ED;">
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0;font-size:0;mso-line-height-rule:exactly;"><v:image style="border:0;mso-position-horizontal:center;position:absolute;top:0;width:600px;z-index:-3;" src="{{ MAIN_HOST }}/running_email.jpg" xmlns:v="urn:schemas-microsoft-com:vml" /><![endif]-->
|
||||
<div style="margin:0 auto;max-width:600px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr style="vertical-align:top;">
|
||||
<td background="{{ MAIN_HOST }}/running_email.jpg" style="background:#075985 url('{{ MAIN_HOST }}/running_email.jpg') no-repeat center center / cover;background-position:center center;background-repeat:no-repeat;padding:0px;vertical-align:top;" height="200">
|
||||
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" style="width:600px;" width="600" ><tr><td style=""><![endif]-->
|
||||
<div class="mj-hero-content" style="margin:0px auto;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;margin:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;margin:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" class="no-invert" style="font-size:0px;padding:10px 25px;padding-top:45px;padding-bottom:10px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:30px;line-height:1.2;text-align:center;color:#ffffff;">Workout Challenge</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#075985" role="presentation" style="border:1px solid #fff;border-radius:24px;cursor:auto;mso-padding-alt:10px 25px;background:#075985;" valign="middle">
|
||||
<a href="{{ MAIN_HOST }}/login" style="display:inline-block;background:#075985;color:#ffffff;font-family:Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:24px;" target="_blank"> LOGIN </a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="white" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:white;background-color:white;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:white;background-color:white;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align:top;padding-bottom:0px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<p>Hi {{ first_name }},</p>
|
||||
<p>Let's see where you're on the leaderboard!</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<p style="border-top:solid 2px #E1E8ED;font-size:1px;margin:0px auto;width:100%;">
|
||||
</p>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #E1E8ED;font-size:1px;margin:0px auto;width:550px;" role="presentation" width="550px" ><tr><td style="height:0;line-height:0;">
|
||||
</td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:5px;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<h3>This week's leaderboard:</h3>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
<!-- This week leaderbaord section -->{% for competition in competitions_7d %}
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="white" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:white;background-color:white;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:white;background-color:white;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:10px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:300px;" ><![endif]-->
|
||||
<div class="mj-column-per-50 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;"><b><span style="color:#075985">{{ competition.competition.name }}</span> - Team</b></div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<table cellpadding="0" cellspacing="0" width="100%" border="0" style="color:#000000;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:22px;table-layout:auto;width:100%;border:none;">
|
||||
<mj-raw> {% for team in competition.leaderboard.team %} </mj-raw>
|
||||
<tr style="border-top:1px solid #ecedee;border-bottom:1px solid #ecedee;text-align:left;padding:15px 0;">
|
||||
<td style="padding: 0 15px 0 0;">{% if team.rank is not None %}#{{ team.rank }}{% else %}-/-{% endif %}</td>
|
||||
<td style="padding: 0 15px;">{{ team.name }}</td>
|
||||
<td style="padding: 0 0 0 15px;">{% if team.total_capped is not None %}{{ team.total_capped|floatformat:0 }}P{% endif %}</td>
|
||||
</tr>
|
||||
<mj-raw> {% endfor %} </mj-raw>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:300px;" ><![endif]-->
|
||||
<div class="mj-column-per-50 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;"><b><span style="color:#075985">{{ competition.competition.name }}</span> - Individual</b></div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<table cellpadding="0" cellspacing="0" width="100%" border="0" style="color:#000000;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:22px;table-layout:auto;width:100%;border:none;">
|
||||
<mj-raw> {% for individual in competition.leaderboard.individual %} </mj-raw>
|
||||
<tr style="border-top:1px solid #ecedee;border-bottom:1px solid #ecedee;text-align:left;padding:15px 0;">
|
||||
<td style="padding: 0 15px 0 0;">{% if individual.rank is not None %}#{{ individual.rank }}{% else %}-/-{% endif %}</td>
|
||||
<td style="padding: 0 15px;">{{ individual.username }}</td>
|
||||
<td style="padding: 0 0 0 15px;">{% if individual.total_capped is not None %}{{ individual.total_capped|floatformat:0 }}P{% endif %}</td>
|
||||
</tr>
|
||||
<mj-raw> {% endfor %} </mj-raw>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]--> {% endfor %}
|
||||
<!-- All time leaderbaord section -->
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="white" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:white;background-color:white;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:white;background-color:white;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align:top;padding-bottom:0px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<p style="border-top:solid 2px #E1E8ED;font-size:1px;margin:0px auto;width:100%;">
|
||||
</p>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #E1E8ED;font-size:1px;margin:0px auto;width:550px;" role="presentation" width="550px" ><tr><td style="height:0;line-height:0;">
|
||||
</td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:5px;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<h3>All-time leaderboard:</h3>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]--> {% for competition in competitions_all %}
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="white" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:white;background-color:white;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:white;background-color:white;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:10px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:300px;" ><![endif]-->
|
||||
<div class="mj-column-per-50 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;"><b><span style="color:#075985">{{ competition.competition.name }}</span> - Team</b></div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<table cellpadding="0" cellspacing="0" width="100%" border="0" style="color:#000000;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:22px;table-layout:auto;width:100%;border:none;">
|
||||
<mj-raw> {% for team in competition.leaderboard.team %} </mj-raw>
|
||||
<tr style="border-top:1px solid #ecedee;border-bottom:1px solid #ecedee;text-align:left;padding:15px 0;">
|
||||
<td style="padding: 0 15px 0 0;">{% if team.rank is not None %}#{{ team.rank }}{% else %}-/-{% endif %}</td>
|
||||
<td style="padding: 0 15px;">{{ team.name }}</td>
|
||||
<td style="padding: 0 0 0 15px;">{% if team.total_capped is not None %}{{ team.total_capped|floatformat:0 }}P{% endif %}</td>
|
||||
</tr>
|
||||
<mj-raw> {% endfor %} </mj-raw>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:300px;" ><![endif]-->
|
||||
<div class="mj-column-per-50 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;"><b><span style="color:#075985">{{ competition.competition.name }}</span> - Individual</b></div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<table cellpadding="0" cellspacing="0" width="100%" border="0" style="color:#000000;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:22px;table-layout:auto;width:100%;border:none;">
|
||||
<mj-raw> {% for individual in competition.leaderboard.individual %} </mj-raw>
|
||||
<tr style="border-top:1px solid #ecedee;border-bottom:1px solid #ecedee;text-align:left;padding:15px 0;">
|
||||
<td style="padding: 0 15px 0 0;">{% if individual.rank is not None %}#{{ individual.rank }}{% else %}-/-{% endif %}</td>
|
||||
<td style="padding: 0 15px;">{{ individual.username }}</td>
|
||||
<td style="padding: 0 0 0 15px;">{% if individual.total_capped is not None %}{{ individual.total_capped|floatformat:0 }}P{% endif %}</td>
|
||||
</tr>
|
||||
<mj-raw> {% endfor %} </mj-raw>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]--> {% endfor %}{% if goal_equalizer_note %}
|
||||
<!-- Goal Equalizer -->
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#075985" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:#075985;background-color:#075985;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#075985;background-color:#075985;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:middle;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:10px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:18px;line-height:1.2;text-align:center;color:#ffffff;">Make it fair by enabling the Goal Equalizer!</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-right:100px;padding-bottom:20px;padding-left:100px;word-break:break-word;">
|
||||
<p style="border-top:solid 1px #ffffff;font-size:1px;margin:0px auto;width:100%;">
|
||||
</p>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 1px #ffffff;font-size:1px;margin:0px auto;width:400px;" role="presentation" width="400px" ><tr><td style="height:0;line-height:0;">
|
||||
</td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:25px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:11px;line-height:1.2;text-align:center;color:#e5e7eb;">Everyone has a unique <b>Basal Metabolic Rate (BMR)</b>, dependent on factors like age, gender, height, and weight. The default goals are calibrated for a 35y/o 1.8m tall man. To ensure a fair competition, login and personalise your goals using the "Goal Equalizer" right below your personal settings.</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]--> {% endif %}
|
||||
<!-- Final remarks -->
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="white" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:white;background-color:white;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:white;background-color:white;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:15px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody> {% if not goal_equalizer_note %} <tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-bottom:0px;word-break:break-word;">
|
||||
<p style="border-top:solid 2px #E1E8ED;font-size:1px;margin:0px auto;width:100%;">
|
||||
</p>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #E1E8ED;font-size:1px;margin:0px auto;width:550px;" role="presentation" width="550px" ><tr><td style="height:0;line-height:0;">
|
||||
</td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr> {% endif %} <tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:20px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">Login to see more detailed analyses, charts, and breakdowns:</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;padding-top:5px;padding-bottom:5px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#075985" role="presentation" style="border:none;border-radius:24px;cursor:auto;mso-padding-alt:10px 25px;background:#075985;" valign="middle">
|
||||
<a href="{{ MAIN_HOST }}/login" style="display:inline-block;background:#075985;color:#ffffff;font-family:Helvetica, Arial, sans-serif, Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:24px;" target="_blank"> LOGIN </a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<p>Enjoy the Competition.</p>
|
||||
<p>Good luck!</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
<!-- Footnote -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:middle;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:11px;line-height:1.2;text-align:center;color:#9a9ea6;"><span class="apple-link">Workout Challenge</span>
|
||||
<br /> Any issues? <a href="mailto:{{ EMAIL_REPLY_TO }}">Send us an Email</a>. <br /> See the <a href="https://github.com/vanalmsick/workout_challenge">Source Code</a>.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
145
src-backend/custom_user/templates/email_leaderboard.mjml
Normal file
145
src-backend/custom_user/templates/email_leaderboard.mjml
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
<mjml>
|
||||
<!-- Written with https://mjml.io/ -->
|
||||
<mj-head>
|
||||
<mj-preview>New week - fresh start! But first let's see where you're on the Leaderbaord for last week!</mj-preview>
|
||||
<mj-attributes>
|
||||
<mj-text line-height="1.2" />
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
<mj-body background-color="#E1E8ED">
|
||||
<mj-hero mode="fixed-height" height="200px" background-url="{{ MAIN_HOST }}/running_email.jpg" background-size="cover" background-repeat="no-repeat" background-color="#075985">
|
||||
<mj-text align="center" font-size="30px" color="#ffffff" padding-bottom="10px" padding-top="45px" css-class="no-invert">
|
||||
Workout Challenge
|
||||
</mj-text>
|
||||
<mj-button background-color="#075985" color="#fff" border-radius="24px" border="1px solid #fff" href="{{ MAIN_HOST }}/login" font-family="Helvetica, Arial, sans-serif" padding="10px 25px">
|
||||
LOGIN
|
||||
</mj-button>
|
||||
</mj-hero>
|
||||
<mj-section padding-top="0px" padding-bottom="0px" background-color="white">
|
||||
<mj-column padding-bottom="0px">
|
||||
<mj-text padding-bottom="0px">
|
||||
<p>Hi {{ first_name }},</p>
|
||||
<p>Let's see where you're on the leaderboard!</p>
|
||||
</mj-text>
|
||||
<mj-divider border-color="#E1E8ED" border-width="2px"></mj-divider>
|
||||
<mj-text padding-top="5px" padding-bottom="0px">
|
||||
<h3>This week's leaderboard:</h3>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<!-- This week leaderbaord section -->
|
||||
<mj-raw> {% for competition in competitions_7d %} </mj-raw>
|
||||
<mj-section padding-top="0px" padding-bottom="10px" background-color="white">
|
||||
<mj-column>
|
||||
<mj-text>
|
||||
<b><span style="color:#075985">{{ competition.competition.name }}</span> - Team</b>
|
||||
</mj-text>
|
||||
<mj-table>
|
||||
<mj-raw> {% for team in competition.leaderboard.team %} </mj-raw>
|
||||
<tr style="border-top:1px solid #ecedee;border-bottom:1px solid #ecedee;text-align:left;padding:15px 0;">
|
||||
<td style="padding: 0 15px 0 0;">{% if team.rank is not None %}#{{ team.rank }}{% else %}-/-{% endif %}</td>
|
||||
<td style="padding: 0 15px;">{{ team.name }}</td>
|
||||
<td style="padding: 0 0 0 15px;">{% if team.total_capped is not None %}{{ team.total_capped|floatformat:0 }}P{% endif %}</td>
|
||||
</tr>
|
||||
<mj-raw> {% endfor %} </mj-raw>
|
||||
</mj-table>
|
||||
</mj-column>
|
||||
<mj-column>
|
||||
<mj-text>
|
||||
<b><span style="color:#075985">{{ competition.competition.name }}</span> - Individual</b>
|
||||
</mj-text>
|
||||
<mj-table>
|
||||
<mj-raw> {% for individual in competition.leaderboard.individual %} </mj-raw>
|
||||
<tr style="border-top:1px solid #ecedee;border-bottom:1px solid #ecedee;text-align:left;padding:15px 0;">
|
||||
<td style="padding: 0 15px 0 0;">{% if individual.rank is not None %}#{{ individual.rank }}{% else %}-/-{% endif %}</td>
|
||||
<td style="padding: 0 15px;">{{ individual.username }}</td>
|
||||
<td style="padding: 0 0 0 15px;">{% if individual.total_capped is not None %}{{ individual.total_capped|floatformat:0 }}P{% endif %}</td>
|
||||
</tr>
|
||||
<mj-raw> {% endfor %} </mj-raw>
|
||||
</mj-table>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-raw> {% endfor %} </mj-raw>
|
||||
|
||||
<!-- All time leaderbaord section -->
|
||||
<mj-section padding-top="0px" padding-bottom="0px" background-color="white">
|
||||
<mj-column padding-bottom="0px">
|
||||
<mj-divider border-color="#E1E8ED" border-width="2px"></mj-divider>
|
||||
<mj-text padding-top="5px" padding-bottom="0px">
|
||||
<h3>All-time leaderboard:</h3>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-raw> {% for competition in competitions_all %} </mj-raw>
|
||||
<mj-section padding-top="0px" padding-bottom="10px" background-color="white">
|
||||
<mj-column>
|
||||
<mj-text>
|
||||
<b><span style="color:#075985">{{ competition.competition.name }}</span> - Team</b>
|
||||
</mj-text>
|
||||
<mj-table>
|
||||
<mj-raw> {% for team in competition.leaderboard.team %} </mj-raw>
|
||||
<tr style="border-top:1px solid #ecedee;border-bottom:1px solid #ecedee;text-align:left;padding:15px 0;">
|
||||
<td style="padding: 0 15px 0 0;">{% if team.rank is not None %}#{{ team.rank }}{% else %}-/-{% endif %}</td>
|
||||
<td style="padding: 0 15px;">{{ team.name }}</td>
|
||||
<td style="padding: 0 0 0 15px;">{% if team.total_capped is not None %}{{ team.total_capped|floatformat:0 }}P{% endif %}</td>
|
||||
</tr>
|
||||
<mj-raw> {% endfor %} </mj-raw>
|
||||
</mj-table>
|
||||
</mj-column>
|
||||
<mj-column>
|
||||
<mj-text>
|
||||
<b><span style="color:#075985">{{ competition.competition.name }}</span> - Individual</b>
|
||||
</mj-text>
|
||||
<mj-table>
|
||||
<mj-raw> {% for individual in competition.leaderboard.individual %} </mj-raw>
|
||||
<tr style="border-top:1px solid #ecedee;border-bottom:1px solid #ecedee;text-align:left;padding:15px 0;">
|
||||
<td style="padding: 0 15px 0 0;">{% if individual.rank is not None %}#{{ individual.rank }}{% else %}-/-{% endif %}</td>
|
||||
<td style="padding: 0 15px;">{{ individual.username }}</td>
|
||||
<td style="padding: 0 0 0 15px;">{% if individual.total_capped is not None %}{{ individual.total_capped|floatformat:0 }}P{% endif %}</td>
|
||||
</tr>
|
||||
<mj-raw> {% endfor %} </mj-raw>
|
||||
</mj-table>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-raw> {% endfor %} </mj-raw>
|
||||
|
||||
<mj-raw> {% if goal_equalizer_note %} </mj-raw>
|
||||
<!-- Goal Equalizer -->
|
||||
<mj-section background-color="#075985" vertical-align="middle">
|
||||
<mj-column width="100%" vertical-align="middle">
|
||||
<mj-text align="center" color="#ffffff" font-size="18px" padding-bottom="10px" padding-top="20px">Make it fair by enabling the Goal Equalizer!</mj-text>
|
||||
<mj-divider border-color="#fff" border-style="solid" border-width="1px" padding-left="100px" padding-right="100px" padding-bottom="20px"></mj-divider>
|
||||
<mj-text align="center" color="#e5e7eb" font-size="11px" padding-bottom="25px" padding-top="0px">Everyone has a unique <b>Basal Metabolic Rate (BMR)</b>, dependent on factors like age, gender, height, and weight. The default goals are calibrated for a 35y/o 1.8m tall man. To ensure a fair competition, login and personalise your goals using the "Goal Equalizer" right below your personal settings.</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-raw> {% endif %} </mj-raw>
|
||||
|
||||
<!-- Final remarks -->
|
||||
<mj-section padding-top="0px" padding-bottom="15px" background-color="white">
|
||||
<mj-column>
|
||||
<mj-raw> {% if not goal_equalizer_note %} </mj-raw>
|
||||
<mj-divider padding-bottom="0px" border-color="#E1E8ED" border-width="2px"></mj-divider>
|
||||
<mj-raw> {% endif %} </mj-raw>
|
||||
<mj-text padding-top="20px">Login to see more detailed analyses, charts, and breakdowns:</mj-text>
|
||||
<mj-button align="center" background-color="#075985" color="#fff" border-radius="24px" href="{{ MAIN_HOST }}/login" font-family="Helvetica, Arial, sans-serif, Helvetica, Arial, sans-serif" padding-bottom="5px" padding-top="5px">LOGIN</mj-button>
|
||||
<mj-text padding-bottom="0px" padding-top="0px">
|
||||
<p>Enjoy the Competition.</p>
|
||||
<p>Good luck!</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<!-- Footnote -->
|
||||
</mj-section>
|
||||
<mj-section full-width="full-width">
|
||||
<mj-column width="100%" vertical-align="middle">
|
||||
<mj-text align="center" color="#9a9ea6" font-size="11px" padding-bottom="0px" padding-top="0">
|
||||
<span class="apple-link">Workout Challenge</span>
|
||||
<br/> Any issues? <a href="mailto:{{ EMAIL_REPLY_TO }}">Send us an Email</a>. <br/> See the <a href="https://github.com/vanalmsick/workout_challenge">Source Code</a>.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
</mj-body>
|
||||
</mjml>
|
||||
377
src-backend/custom_user/templates/email_log_workouts.html
Normal file
377
src-backend/custom_user/templates/email_log_workouts.html
Normal file
|
|
@ -0,0 +1,377 @@
|
|||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>
|
||||
</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
</style>
|
||||
<style type="text/css">
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="word-spacing:normal;background-color:#E1E8ED;">
|
||||
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;"> New week, new gains! But first time to log last week's workouts before we send out the leaderboard this afternoon! </div>
|
||||
<div style="background-color:#E1E8ED;">
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0;font-size:0;mso-line-height-rule:exactly;"><v:image style="border:0;mso-position-horizontal:center;position:absolute;top:0;width:600px;z-index:-3;" src="{{ MAIN_HOST }}/running_email.jpg" xmlns:v="urn:schemas-microsoft-com:vml" /><![endif]-->
|
||||
<div style="margin:0 auto;max-width:600px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr style="vertical-align:top;">
|
||||
<td background="{{ MAIN_HOST }}/running_email.jpg" style="background:#075985 url('{{ MAIN_HOST }}/running_email.jpg') no-repeat center center / cover;background-position:center center;background-repeat:no-repeat;padding:0px;vertical-align:top;" height="200">
|
||||
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" style="width:600px;" width="600" ><tr><td style=""><![endif]-->
|
||||
<div class="mj-hero-content" style="margin:0px auto;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;margin:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;margin:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" class="no-invert" style="font-size:0px;padding:10px 25px;padding-top:45px;padding-bottom:10px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:30px;line-height:1.2;text-align:center;color:#ffffff;">Workout Challenge</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#075985" role="presentation" style="border:1px solid #fff;border-radius:24px;cursor:auto;mso-padding-alt:10px 25px;background:#075985;" valign="middle">
|
||||
<a href="{{ MAIN_HOST }}/login" style="display:inline-block;background:#075985;color:#ffffff;font-family:Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:24px;" target="_blank"> LOGIN </a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="white" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:white;background-color:white;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:white;background-color:white;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align:top;padding-bottom:0px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<p>Hi {{ first_name }},</p>
|
||||
<p>New week, new gains!</p>
|
||||
<mj-raw> {% if link_strava_note %} </mj-raw>
|
||||
<p>But first, time to <b>log last week's workouts & steps</b> before we send out the leaderboard this afternoon!</p>
|
||||
<mj-raw> {% else %} </mj-raw>
|
||||
<p>But first, time to <b>log last week's steps</b> as Strava does not automatically import steps. Your workouts should have been imported already. We will send out the leaderboard this afternoon!</p>
|
||||
<mj-raw> {% endif %} </mj-raw>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<p style="border-top:solid 2px #E1E8ED;font-size:1px;margin:0px auto;width:100%;">
|
||||
</p>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #E1E8ED;font-size:1px;margin:0px auto;width:550px;" role="presentation" width="550px" ><tr><td style="height:0;line-height:0;">
|
||||
</td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<h3>Your last workouts:</h3>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
<!-- This week leaderbaord section -->
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="white" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:white;background-color:white;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:white;background-color:white;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<table cellpadding="0" cellspacing="0" width="100%" border="0" style="color:#000000;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:22px;table-layout:auto;width:100%;border:none;">
|
||||
<tr style="border-top:1px solid #ecedee;border-bottom:1px solid #ecedee;text-align:left;padding:15px 0;">
|
||||
<th style="padding: 0 15px 0 0;">Time</th>
|
||||
<th style="padding: 0 15px;">Workout</th>
|
||||
</tr>
|
||||
<mj-raw> {% for item in last_workouts %} </mj-raw>
|
||||
<tr style="border-top:1px solid #ecedee;border-bottom:1px solid #ecedee;text-align:left;padding:15px 0;">
|
||||
<td style="padding: 0 15px 0 0;">{{ item.start_datetime|date:"D, M j H:i" }}</td>
|
||||
<td style="padding: 0 15px;">{% if item.sport_type == "Steps" %}{{ item.steps }}{% else %}{{ item.duration }}{% endif %} <b>{{ item.sport_type }}</b></td>
|
||||
</tr>
|
||||
<mj-raw> {% endfor %} </mj-raw>
|
||||
</table>
|
||||
</td>
|
||||
</tr> {% if link_strava_note %} <tr>
|
||||
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;padding-top:7px;padding-bottom:17px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#075985" role="presentation" style="border:none;border-radius:24px;cursor:auto;mso-padding-alt:10px 25px;background:#075985;" valign="middle">
|
||||
<a href="{{ MAIN_HOST }}/dashboard" style="display:inline-block;background:#075985;color:#ffffff;font-family:Helvetica, Arial, sans-serif, Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:24px;" target="_blank"> Log Workouts & Steps </a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr> {% else %} <tr>
|
||||
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;padding-top:7px;padding-bottom:17px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#075985" role="presentation" style="border:none;border-radius:24px;cursor:auto;mso-padding-alt:10px 25px;background:#075985;" valign="middle">
|
||||
<a href="{{ MAIN_HOST }}/dashboard" style="display:inline-block;background:#075985;color:#ffffff;font-family:Helvetica, Arial, sans-serif, Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:24px;" target="_blank"> Log Steps </a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr> {% endif %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
<!-- Link Strava -->{% if link_strava_note %}
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#075985" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:#075985;background-color:#075985;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#075985;background-color:#075985;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:middle;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:10px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:18px;line-height:1.2;text-align:center;color:#ffffff;">Skip the hassle of manual logging – connect Strava for automatic workout imports!</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-right:100px;padding-bottom:20px;padding-left:100px;word-break:break-word;">
|
||||
<p style="border-top:solid 1px #ffffff;font-size:1px;margin:0px auto;width:100%;">
|
||||
</p>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 1px #ffffff;font-size:1px;margin:0px auto;width:400px;" role="presentation" width="400px" ><tr><td style="height:0;line-height:0;">
|
||||
</td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:25px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:11px;line-height:1.2;text-align:center;color:#e5e7eb;">
|
||||
<p>Every day at 4 AM, your workouts are automatically imported, only syncing the most essential data with Strava to respect your privacy:</p>
|
||||
<span>• Sport type</span><br />
|
||||
<span>• Start time & duration</span><br />
|
||||
<span>• Workout id</span><br />
|
||||
<span>• Distance, kcal, kj, avg. watt</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:5px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#ffffff" role="presentation" style="border:none;border-radius:24px;cursor:auto;mso-padding-alt:10px 25px;background:#ffffff;" valign="middle">
|
||||
<a href="{{ MAIN_HOST }}/strava/link" style="display:inline-block;background:#ffffff;color:#075985;font-family:Helvetica, Arial, sans-serif, Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:24px;" target="_blank"> Link Strava </a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]--> {% endif %}
|
||||
<!-- Final remarks -->
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="white" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:white;background-color:white;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:white;background-color:white;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:5px;padding-bottom:10px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<p>Enjoy the Competition.</p>
|
||||
<p>Good luck!</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
<!-- Footnote -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:middle;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:11px;line-height:1.2;text-align:center;color:#9a9ea6;"><span class="apple-link">Workout Challenge</span>
|
||||
<br /> Any issues? <a href="mailto:{{ EMAIL_REPLY_TO }}">Send us an Email</a>. <br /> See the <a href="https://github.com/vanalmsick/workout_challenge">Source Code</a>.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
99
src-backend/custom_user/templates/email_log_workouts.mjml
Normal file
99
src-backend/custom_user/templates/email_log_workouts.mjml
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<mjml>
|
||||
<!-- Written with https://mjml.io/ -->
|
||||
<mj-head>
|
||||
<mj-preview>New week, new gains! But first time to log last week's workouts before we send out the leaderboard this afternoon!</mj-preview>
|
||||
<mj-attributes>
|
||||
<mj-text line-height="1.2" />
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
<mj-body background-color="#E1E8ED">
|
||||
<mj-hero mode="fixed-height" height="200px" background-url="{{ MAIN_HOST }}/running_email.jpg" background-size="cover" background-repeat="no-repeat" background-color="#075985">
|
||||
<mj-text align="center" font-size="30px" color="#ffffff" padding-bottom="10px" padding-top="45px" css-class="no-invert">
|
||||
Workout Challenge
|
||||
</mj-text>
|
||||
<mj-button background-color="#075985" color="#fff" border-radius="24px" border="1px solid #fff" href="{{ MAIN_HOST }}/login" font-family="Helvetica, Arial, sans-serif" padding="10px 25px">
|
||||
LOGIN
|
||||
</mj-button>
|
||||
</mj-hero>
|
||||
<mj-section padding-top="0px" padding-bottom="0px" background-color="white">
|
||||
<mj-column padding-bottom="0px">
|
||||
<mj-text padding-bottom="0px">
|
||||
<p>Hi {{ first_name }},</p>
|
||||
<p>New week, new gains!</p>
|
||||
<mj-raw> {% if link_strava_note %} </mj-raw>
|
||||
<p>But first, time to <b>log last week's workouts & steps</b> before we send out the leaderboard this afternoon!</p>
|
||||
<mj-raw> {% else %} </mj-raw>
|
||||
<p>But first, time to <b>log last week's steps</b> as Strava does not automatically import steps. Your workouts should have been imported already. We will send out the leaderboard this afternoon!</p>
|
||||
<mj-raw> {% endif %} </mj-raw>
|
||||
</mj-text>
|
||||
<mj-divider border-color="#E1E8ED" border-width="2px"></mj-divider>
|
||||
<mj-text padding-bottom="0px">
|
||||
<h3>Your last workouts:</h3>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<!-- This week leaderbaord section -->
|
||||
<mj-section padding-top="0px" padding-bottom="0px" background-color="white">
|
||||
<mj-column>
|
||||
<mj-table>
|
||||
<tr style="border-top:1px solid #ecedee;border-bottom:1px solid #ecedee;text-align:left;padding:15px 0;">
|
||||
<th style="padding: 0 15px 0 0;">Time</th>
|
||||
<th style="padding: 0 15px;">Workout</th>
|
||||
</tr>
|
||||
<mj-raw> {% for item in last_workouts %} </mj-raw>
|
||||
<tr style="border-top:1px solid #ecedee;border-bottom:1px solid #ecedee;text-align:left;padding:15px 0;">
|
||||
<td style="padding: 0 15px 0 0;">{{ item.start_datetime|date:"D, M j H:i" }}</td>
|
||||
<td style="padding: 0 15px;">{% if item.sport_type == "Steps" %}{{ item.steps }}{% else %}{{ item.duration }}{% endif %} <b>{{ item.sport_type }}</b></td>
|
||||
</tr>
|
||||
<mj-raw> {% endfor %} </mj-raw>
|
||||
</mj-table>
|
||||
<mj-raw> {% if link_strava_note %} </mj-raw>
|
||||
<mj-button align="center" background-color="#075985" color="#fff" border-radius="24px" href="{{ MAIN_HOST }}/dashboard" font-family="Helvetica, Arial, sans-serif, Helvetica, Arial, sans-serif" padding-bottom="17px" padding-top="7px">Log Workouts & Steps</mj-button>
|
||||
<mj-raw> {% else %} </mj-raw>
|
||||
<mj-button align="center" background-color="#075985" color="#fff" border-radius="24px" href="{{ MAIN_HOST }}/dashboard" font-family="Helvetica, Arial, sans-serif, Helvetica, Arial, sans-serif" padding-bottom="17px" padding-top="7px">Log Steps</mj-button>
|
||||
<mj-raw> {% endif %} </mj-raw>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<!-- Link Strava -->
|
||||
<mj-raw> {% if link_strava_note %} </mj-raw>
|
||||
<mj-section background-color="#075985" vertical-align="middle">
|
||||
<mj-column width="100%" vertical-align="middle">
|
||||
<mj-text align="center" color="#ffffff" font-size="18px" padding-bottom="10px" padding-top="20px">Skip the hassle of manual logging – connect Strava for automatic workout imports!</mj-text>
|
||||
<mj-divider border-color="#fff" border-style="solid" border-width="1px" padding-left="100px" padding-right="100px" padding-bottom="20px"></mj-divider>
|
||||
<mj-text align="center" color="#e5e7eb" font-size="11px" padding-bottom="25px" padding-top="0px">
|
||||
<p>Every day at 4 AM, your workouts are automatically imported, only syncing the most essential data with Strava to respect your privacy:</p>
|
||||
<span>• Sport type</span><br/>
|
||||
<span>• Start time & duration</span><br/>
|
||||
<span>• Workout id</span><br/>
|
||||
<span>• Distance, kcal, kj, avg. watt</span>
|
||||
</mj-text>
|
||||
<mj-button align="center" background-color="#ffffff" color="#075985" border-radius="24px" href="{{ MAIN_HOST }}/strava/link" font-family="Helvetica, Arial, sans-serif, Helvetica, Arial, sans-serif" padding-bottom="5px" padding-top="0px">Link Strava</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-raw> {% endif %} </mj-raw>
|
||||
|
||||
<!-- Final remarks -->
|
||||
<mj-section padding-top="0px" padding-bottom="0px" background-color="white">
|
||||
<mj-column>
|
||||
<mj-text padding-bottom="10px" padding-top="5px">
|
||||
<p>Enjoy the Competition.</p>
|
||||
<p>Good luck!</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<!-- Footnote -->
|
||||
</mj-section>
|
||||
<mj-section full-width="full-width">
|
||||
<mj-column width="100%" vertical-align="middle">
|
||||
<mj-text align="center" color="#9a9ea6" font-size="11px" padding-bottom="0px" padding-top="0">
|
||||
<span class="apple-link">Workout Challenge</span>
|
||||
<br/> Any issues? <a href="mailto:{{ EMAIL_REPLY_TO }}">Send us an Email</a>. <br/> See the <a href="https://github.com/vanalmsick/workout_challenge">Source Code</a>.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
</mj-body>
|
||||
</mjml>
|
||||
220
src-backend/custom_user/templates/email_password_reset.html
Normal file
220
src-backend/custom_user/templates/email_password_reset.html
Normal file
|
|
@ -0,0 +1,220 @@
|
|||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>
|
||||
</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
</style>
|
||||
<style type="text/css">
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="word-spacing:normal;background-color:#E1E8ED;">
|
||||
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;"> We received a request to reset your password. Click the link below or copy and paste it into your browser to reset your password (valid for 10 minutes). </div>
|
||||
<div style="background-color:#E1E8ED;">
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0;font-size:0;mso-line-height-rule:exactly;"><v:image style="border:0;mso-position-horizontal:center;position:absolute;top:0;width:600px;z-index:-3;" src="{{ MAIN_HOST }}/running_email.jpg" xmlns:v="urn:schemas-microsoft-com:vml" /><![endif]-->
|
||||
<div style="margin:0 auto;max-width:600px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr style="vertical-align:top;">
|
||||
<td background="{{ MAIN_HOST }}/running_email.jpg" style="background:#075985 url('{{ MAIN_HOST }}/running_email.jpg') no-repeat center center / cover;background-position:center center;background-repeat:no-repeat;padding:0px;vertical-align:top;" height="150">
|
||||
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" style="width:600px;" width="600" ><tr><td style=""><![endif]-->
|
||||
<div class="mj-hero-content" style="margin:0px auto;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;margin:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;margin:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" class="no-invert" style="font-size:0px;padding:10px 25px;padding-top:65px;padding-bottom:65px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:30px;line-height:1.2;text-align:center;color:#ffffff;">Workout Challenge</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="white" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:white;background-color:white;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:white;background-color:white;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align:top;padding-bottom:0px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<p>Hi {{ first_name }},</p>
|
||||
<p>We received a request to reset your password.</p>
|
||||
<p>Click the link below or copy and paste it into your browser to reset your password (valid for 10 minutes):</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;padding-top:10px;padding-bottom:0px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#075985" role="presentation" style="border:none;border-radius:24px;cursor:auto;mso-padding-alt:10px 25px;background:#075985;" valign="middle">
|
||||
<a href="{{ RESET_URL }}" style="display:inline-block;background:#075985;color:#ffffff;font-family:Helvetica, Arial, sans-serif, Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:24px;" target="_blank"> Reset Password </a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:15px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<p align="center"><a href="{{ RESET_URL }}">{{ RESET_URL }}</a></p>
|
||||
<p>If you didn’t request this, you can safely ignore this email.</p>
|
||||
<p>Enjoy the Competition.</p>
|
||||
<p>Good luck!</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
<!-- Footnote -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:middle;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:11px;line-height:1.2;text-align:center;color:#9a9ea6;"><span class="apple-link">Workout Challenge</span>
|
||||
<br> Any issues? <a href="mailto:{{ EMAIL_REPLY_TO }}">Send us an Email</a>. <br> See the <a href="https://github.com/vanalmsick/workout_challenge">Source Code</a>.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
44
src-backend/custom_user/templates/email_password_reset.mjml
Normal file
44
src-backend/custom_user/templates/email_password_reset.mjml
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
<mjml>
|
||||
<!-- Written with https://mjml.io/ -->
|
||||
<mj-head>
|
||||
<mj-preview>We received a request to reset your password. Click the link below or copy and paste it into your browser to reset your password (valid for 10 minutes).</mj-preview>
|
||||
<mj-attributes>
|
||||
<mj-text line-height="1.2" />
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
<mj-body background-color="#E1E8ED">
|
||||
<mj-hero mode="fixed-height" height="150px" background-url="{{ MAIN_HOST }}/running_email.jpg" background-size="cover" background-repeat="no-repeat" background-color="#075985">
|
||||
<mj-text align="center" font-size="30px" color="#ffffff" padding-bottom="65px" padding-top="65px" css-class="no-invert">
|
||||
Workout Challenge
|
||||
</mj-text>
|
||||
</mj-hero>
|
||||
<mj-section padding-top="0px" padding-bottom="0px" background-color="white">
|
||||
<mj-column padding-bottom="0px">
|
||||
<mj-text padding-bottom="0px">
|
||||
<p>Hi {{ first_name }},</p>
|
||||
<p>We received a request to reset your password.</p>
|
||||
<p>Click the link below or copy and paste it into your browser to reset your password (valid for 10 minutes):</p>
|
||||
</mj-text>
|
||||
<mj-button align="center" background-color="#075985" color="#fff" border-radius="24px" href="{{ RESET_URL }}" font-family="Helvetica, Arial, sans-serif, Helvetica, Arial, sans-serif" padding-bottom="0px" padding-top="10px">Reset Password</mj-button>
|
||||
<mj-text padding-top="0px" padding-bottom="15px">
|
||||
<p align="center"><a href="{{ RESET_URL }}">{{ RESET_URL }}</a></p>
|
||||
<p>If you didn’t request this, you can safely ignore this email.</p>
|
||||
<p>Enjoy the Competition.</p>
|
||||
<p>Good luck!</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<!-- Footnote -->
|
||||
</mj-section>
|
||||
<mj-section full-width="full-width">
|
||||
<mj-column width="100%" vertical-align="middle">
|
||||
<mj-text align="center" color="#9a9ea6" font-size="11px" padding-bottom="0px" padding-top="0">
|
||||
<span class="apple-link">Workout Challenge</span>
|
||||
<br> Any issues? <a href="mailto:{{ EMAIL_REPLY_TO }}">Send us an Email</a>. <br> See the <a href="https://github.com/vanalmsick/workout_challenge">Source Code</a>.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
</mj-body>
|
||||
</mjml>
|
||||
518
src-backend/custom_user/templates/email_weekly.html
Normal file
518
src-backend/custom_user/templates/email_weekly.html
Normal file
|
|
@ -0,0 +1,518 @@
|
|||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>
|
||||
</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mj-column-per-50 {
|
||||
width: 50% !important;
|
||||
max-width: 50%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.moz-text-html .mj-column-per-50 {
|
||||
width: 50% !important;
|
||||
max-width: 50%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
</style>
|
||||
<style type="text/css">
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="word-spacing:normal;background-color:#E1E8ED;">
|
||||
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;"> Keep the momentum going – keep the streak alive! Aim for 150-300 minutes of moderate or 75-150 minutes of vigorous activity weekly (or an equivalent mix) spread over at least 2 days, as recommended by the WHO. </div>
|
||||
<div style="background-color:#E1E8ED;">
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0;font-size:0;mso-line-height-rule:exactly;"><v:image style="border:0;mso-position-horizontal:center;position:absolute;top:0;width:600px;z-index:-3;" src="{{ MAIN_HOST }}/running_email.jpg" xmlns:v="urn:schemas-microsoft-com:vml" /><![endif]-->
|
||||
<div style="margin:0 auto;max-width:600px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr style="vertical-align:top;">
|
||||
<td background="{{ MAIN_HOST }}/running_email.jpg" style="background:#075985 url('{{ MAIN_HOST }}/running_email.jpg') no-repeat center center / cover;background-position:center center;background-repeat:no-repeat;padding:0px;vertical-align:top;" height="200">
|
||||
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" style="width:600px;" width="600" ><tr><td style=""><![endif]-->
|
||||
<div class="mj-hero-content" style="margin:0px auto;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;margin:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style>
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;margin:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" class="no-invert" style="font-size:0px;padding:10px 25px;padding-top:45px;padding-bottom:10px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:30px;line-height:1.2;text-align:center;color:#ffffff;">Workout Challenge</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#075985" role="presentation" style="border:1px solid #fff;border-radius:24px;cursor:auto;mso-padding-alt:10px 25px;background:#075985;" valign="middle">
|
||||
<a href="{{ MAIN_HOST }}/login" style="display:inline-block;background:#075985;color:#ffffff;font-family:Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:24px;" target="_blank"> LOGIN </a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="white" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:white;background-color:white;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:white;background-color:white;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align:top;padding-bottom:0px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<p>Hi {{ first_name }},</p>
|
||||
<p>Let’s check in on your week!</p>
|
||||
<p>Keep the momentum going – keep the streak alive!</p>
|
||||
<p>Aim for 150-300 minutes of moderate or 75-150 minutes of vigorous activity weekly (or an equivalent mix) spread over at least 2 days, as recommended by the WHO.</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<p style="border-top:solid 2px #E1E8ED;font-size:1px;margin:0px auto;width:100%;">
|
||||
</p>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #E1E8ED;font-size:1px;margin:0px auto;width:550px;" role="presentation" width="550px" ><tr><td style="height:0;line-height:0;">
|
||||
</td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="white" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:white;background-color:white;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:white;background-color:white;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-top:5px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:300px;" ><![endif]-->
|
||||
<div class="mj-column-per-50 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align:top;padding-top:0px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<h3>Your Streak:</h3>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;word-break:break-word;">
|
||||
<table cellpadding="0" cellspacing="0" width="100%" border="0" style="color:#000000;font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:22px;table-layout:auto;width:100%;border:none;">
|
||||
<tr class="calendar" style="width: 35px; height: 35px; text-align: center; padding: 0px;" align="center">
|
||||
<td style="font-weight: bold;">
|
||||
<!--[if mso]><v:oval xmlns:v="urn:schemas-microsoft-com:vml" style="height:30px;width:30px;" strokecolor="white" strokeweight="1px" fillcolor="FFFFFF"><v:textbox inset="0,0,0,0"><table width="100%" height="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" valign="middle"><font style="font-size:14px; color:#000000;">M</font></td></tr></table></v:textbox></v:oval><![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<div class="day" style="height: 25px; width: 25px; margin-left: auto; margin-right: auto; justify-content: center; align-items: center; display: flex; border-radius: 9999px; background-color: #FFFFFF; color: #000000;">M</div>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
<td style="font-weight: bold;">
|
||||
<!--[if mso]><v:oval xmlns:v="urn:schemas-microsoft-com:vml" style="height:30px;width:30px;" strokecolor="white" strokeweight="1px" fillcolor="FFFFFF"><v:textbox inset="0,0,0,0"><table width="100%" height="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" valign="middle"><font style="font-size:14px; color:#000000;">T</font></td></tr></table></v:textbox></v:oval><![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<div class="day" style="height: 25px; width: 25px; margin-left: auto; margin-right: auto; justify-content: center; align-items: center; display: flex; border-radius: 9999px; background-color: #FFFFFF; color: #000000;">T</div>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
<td style="font-weight: bold;">
|
||||
<!--[if mso]><v:oval xmlns:v="urn:schemas-microsoft-com:vml" style="height:30px;width:30px;" strokecolor="white" strokeweight="1px" fillcolor="FFFFFF"><v:textbox inset="0,0,0,0"><table width="100%" height="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" valign="middle"><font style="font-size:14px; color:#000000;">W</font></td></tr></table></v:textbox></v:oval><![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<div class="day" style="height: 25px; width: 25px; margin-left: auto; margin-right: auto; justify-content: center; align-items: center; display: flex; border-radius: 9999px; background-color: #FFFFFF; color: #000000;">W</div>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
<td style="font-weight: bold;">
|
||||
<!--[if mso]><v:oval xmlns:v="urn:schemas-microsoft-com:vml" style="height:30px;width:30px;" strokecolor="white" strokeweight="1px" fillcolor="FFFFFF"><v:textbox inset="0,0,0,0"><table width="100%" height="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" valign="middle"><font style="font-size:14px; color:#000000;">T</font></td></tr></table></v:textbox></v:oval><![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<div class="day" style="height: 25px; width: 25px; margin-left: auto; margin-right: auto; justify-content: center; align-items: center; display: flex; border-radius: 9999px; background-color: #FFFFFF; color: #000000;">T</div>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
<td style="font-weight: bold;">
|
||||
<!--[if mso]><v:oval xmlns:v="urn:schemas-microsoft-com:vml" style="height:30px;width:30px;" strokecolor="white" strokeweight="1px" fillcolor="FFFFFF"><v:textbox inset="0,0,0,0"><table width="100%" height="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" valign="middle"><font style="font-size:14px; color:#000000;">F</font></td></tr></table></v:textbox></v:oval><![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<div class="day" style="height: 25px; width: 25px; margin-left: auto; margin-right: auto; justify-content: center; align-items: center; display: flex; border-radius: 9999px; background-color: #FFFFFF; color: #000000;">F</div>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
<td style="font-weight: bold;">
|
||||
<!--[if mso]><v:oval xmlns:v="urn:schemas-microsoft-com:vml" style="height:30px;width:30px;" strokecolor="white" strokeweight="1px" fillcolor="FFFFFF"><v:textbox inset="0,0,0,0"><table width="100%" height="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" valign="middle"><font style="font-size:14px; color:#000000;">S</font></td></tr></table></v:textbox></v:oval><![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<div class="day" style="height: 25px; width: 25px; margin-left: auto; margin-right: auto; justify-content: center; align-items: center; display: flex; border-radius: 9999px; background-color: #FFFFFF; color: #000000;">S</div>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
<td style="font-weight: bold;">
|
||||
<!--[if mso]><v:oval xmlns:v="urn:schemas-microsoft-com:vml" style="height:30px;width:30px;" strokecolor="white" strokeweight="1px" fillcolor="FFFFFF"><v:textbox inset="0,0,0,0"><table width="100%" height="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" valign="middle"><font style="font-size:14px; color:#000000;">S</font></td></tr></table></v:textbox></v:oval><![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<div class="day" style="height: 25px; width: 25px; margin-left: auto; margin-right: auto; justify-content: center; align-items: center; display: flex; border-radius: 9999px; background-color: #FFFFFF; color: #000000;">S</div>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<mj-raw> {% for week in calendar %} </mj-raw>
|
||||
<tr class="calendar" style="width: 35px; height: 35px; text-align: center; padding: 0px;" align="center">
|
||||
<mj-raw> {% for day in week %} </mj-raw>
|
||||
<td>
|
||||
<!--[if mso]><v:oval xmlns:v="urn:schemas-microsoft-com:vml" style="height:30px;width:30px;" strokecolor="white" strokeweight="1px" fillcolor="{{ day.background_color }}"><v:textbox inset="0,0,0,0"><table width="100%" height="100%" cellpadding="0" cellspacing="0" border="0"><tr><td align="center" valign="middle"><font style="font-size:14px; color:{{ day.color }};">{{ day.day }}</font></td></tr></table></v:textbox></v:oval><![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<div class="day" style="height: 25px; width: 25px; margin-left: auto; margin-right: auto; justify-content: center; align-items: center; display: flex; border-radius: 9999px; background-color: {{ day.background_color }}; color: {{ day.color }};">{{ day.day }}</div>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
<mj-raw> {% endfor %} </mj-raw>
|
||||
</tr>
|
||||
<mj-raw> {% endfor %} </mj-raw>
|
||||
<tr>
|
||||
<td colspan="7" style="padding-top:5px;padding-bottom:5px;text-align:center; font-family:Arial, sans-serif; background-color:#F3F7FA;">
|
||||
<span style="display:inline-block; width:25px; height:25px; line-height:25px; text-align:center; mso-line-height-rule:exactly; mso-table-lspace:0pt; mso-table-rspace:0pt; border:2px solid #60A8D8; border-radius:50%; background-color:#60A8D8; color:#FFFFFF; font-weight:bold; font-family:Arial, sans-serif;"> ✓ </span>
|
||||
<span style="display:inline-block; height:25px; line-height: 25px; text-align: center;padding-left:5px; mso-table-lspace:0pt; mso-table-rspace:0pt;color: #6b7280;font-weight:bold;">{{ week_streak }} Week Streak</span>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:300px;" ><![endif]-->
|
||||
<div class="mj-column-per-50 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<h3>Your Personal 7-Day Goals:</h3>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;word-break:break-word;"> {% if goals.active_days is not None %} <table cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="height:10px; font-size:0; line-height:0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color:#f9f9f9; border-radius:8px; padding:18px;">
|
||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="font-size:14px; line-height:16px; mso-line-height-rule:exactly; font-weight:bold; color: #6b7280;">Active Days</td>
|
||||
<td align="right" style="font-size:14px; line-height:16px; mso-line-height-rule:exactly; color: #6b7280;">{{ goals.active_days.recorded }} days <span style="font-size:10px;">/ {{ goals.active_days.target }} days</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" height="12" style="padding-top:8px; line-height:0; font-size:0;">
|
||||
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" arcsize="50%" strokecolor="#e5e5e5" strokeweight="1px" style="width:{{ goals.active_days.percent_vml }}%;height:12px;" fillcolor="#4a78b5"><v:fill color="#4a78b5" /></v:roundrect><![endif]-->
|
||||
<!--[if !mso]><!-- -->
|
||||
<div style="background:#e5e5e5; border-radius:4px; width:100%; height:12px;">
|
||||
<div style="background:#4a78b5; height:12px; border-radius:4px; width:{{ goals.active_days.percent }}%;"></div>
|
||||
</div>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table> {% endif %} {% if goals.minutes is not None %} <table cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="height:15px; font-size:0; line-height:0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color:#f9f9f9; border-radius:8px; padding:18px;">
|
||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="font-size:14px; line-height:16px; mso-line-height-rule:exactly; font-weight:bold; color: #6b7280;">Exercise</td>
|
||||
<td align="right" style="font-size:14px; line-height:16px; mso-line-height-rule:exactly; color: #6b7280;">{{ goals.minutes.recorded }} min <span style="font-size:10px;">/ {{ goals.minutes.target }} min</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" height="12" style="padding-top:8px; line-height:0; font-size:0;">
|
||||
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" arcsize="50%" strokecolor="#e5e5e5" strokeweight="1px" style="width:{{ goals.minutes.percent_vml }}%;height:12px;" fillcolor="#4a78b5"><v:fill color="#4a78b5" /></v:roundrect><![endif]-->
|
||||
<!--[if !mso]><!-- -->
|
||||
<div style="background:#e5e5e5; border-radius:4px; width:100%; height:12px;">
|
||||
<div style="background:#4a78b5; height:12px; border-radius:4px; width:{{ goals.minutes.percent }}%;"></div>
|
||||
</div>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table> {% endif %} {% if goals.distance is not None %} <table cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="height:15px; font-size:0; line-height:0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color:#f9f9f9; border-radius:8px; padding:18px;">
|
||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="font-size:14px; line-height:16px; mso-line-height-rule:exactly; font-weight:bold; color: #6b7280;">Distance</td>
|
||||
<td align="right" style="font-size:14px; line-height:16px; mso-line-height-rule:exactly; color: #6b7280;">{{ goals.distance.recorded }} km <span style="font-size:10px;">/ {{ goals.distance.target }} km</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" height="12" style="padding-top:8px; line-height:0; font-size:0;">
|
||||
<!--[if mso]><v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" arcsize="50%" strokecolor="#e5e5e5" strokeweight="1px" style="width:{{ goals.distance.percent_vml }}%;height:12px;" fillcolor="#4a78b5"><v:fill color="#4a78b5" /></v:roundrect><![endif]-->
|
||||
<!--[if !mso]><!-- -->
|
||||
<div style="background:#e5e5e5; border-radius:4px; width:100%; height:12px;">
|
||||
<div style="background:#4a78b5; height:12px; border-radius:4px; width:{{ goals.distance.percent }}%;"></div>
|
||||
</div>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table> {% endif %} </td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]--> {% if openai_quote is not None %}
|
||||
<!-- AI Health Quote -->
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#075985" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:#075985;background-color:#075985;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#075985;background-color:#075985;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:middle;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:10px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:16px;line-height:1.2;text-align:center;color:#ffffff;">{{ openai_quote }}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-right:100px;padding-bottom:10px;padding-left:100px;word-break:break-word;">
|
||||
<p style="border-top:solid 1px #ffffff;font-size:1px;margin:0px auto;width:100%;">
|
||||
</p>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 1px #ffffff;font-size:1px;margin:0px auto;width:400px;" role="presentation" width="400px" ><tr><td style="height:0;line-height:0;">
|
||||
</td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:11px;line-height:1.2;text-align:center;color:#e5e7eb;">
|
||||
<p>by OpenAI's ChatGPT 4o</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]--> {% endif %}
|
||||
<!-- Final remarks -->
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="white" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:white;background-color:white;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:white;background-color:white;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody> {% if openai_quote is None %} <tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<p style="border-top:solid 2px #E1E8ED;font-size:1px;margin:0px auto;width:100%;">
|
||||
</p>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #E1E8ED;font-size:1px;margin:0px auto;width:550px;" role="presentation" width="550px" ><tr><td style="height:0;line-height:0;">
|
||||
</td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr> {% endif %} <tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:20px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">Login to see more detailed analyses, charts, and breakdowns:</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;padding-top:5px;padding-bottom:2px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#075985" role="presentation" style="border:none;border-radius:24px;cursor:auto;mso-padding-alt:10px 25px;background:#075985;" valign="middle">
|
||||
<a href="{{ MAIN_HOST }}/login" style="display:inline-block;background:#075985;color:#ffffff;font-family:Helvetica, Arial, sans-serif, Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:24px;" target="_blank"> LOGIN </a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:10px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<p>Enjoy the Competition.</p>
|
||||
<p>Good luck!</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
<!-- Footnote -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:middle;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:11px;line-height:1.2;text-align:center;color:#9a9ea6;"><span class="apple-link">Workout Challenge</span>
|
||||
<br> Any issues? <a href="mailto:{{ EMAIL_REPLY_TO }}">Send us an Email</a>. <br> See the <a href="https://github.com/vanalmsick/workout_challenge">Source Code</a>.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
270
src-backend/custom_user/templates/email_weekly.mjml
Normal file
270
src-backend/custom_user/templates/email_weekly.mjml
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
<mjml>
|
||||
<!-- Written with https://mjml.io/ -->
|
||||
<mj-head>
|
||||
<mj-preview>Keep the momentum going – keep the streak alive! Aim for 150-300 minutes of moderate or 75-150 minutes of vigorous activity weekly (or an equivalent mix) spread over at least 2 days, as recommended by the WHO.</mj-preview>
|
||||
<mj-attributes>
|
||||
<mj-text line-height="1.2" />
|
||||
</mj-attributes>
|
||||
<mj-style inline="inline">
|
||||
td div .streak {
|
||||
height: 25px;
|
||||
width: 25px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
border: 2px solid #075971;
|
||||
border-radius: 9999px;
|
||||
color: #075971;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
td div .day {
|
||||
height: 25px;
|
||||
width: 25px;
|
||||
margin-left: auto;
|
||||
margin-right: auto;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
display: flex;
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
.calendar {
|
||||
width: 35px;
|
||||
height: 35px;
|
||||
text-align: center;
|
||||
padding: 0px;
|
||||
}
|
||||
</mj-style>
|
||||
</mj-head>
|
||||
<mj-body background-color="#E1E8ED">
|
||||
<mj-hero mode="fixed-height" height="200px" background-url="{{ MAIN_HOST }}/running_email.jpg" background-size="cover" background-repeat="no-repeat" background-color="#075985">
|
||||
<mj-text align="center" font-size="30px" color="#ffffff" padding-bottom="10px" padding-top="45px" css-class="no-invert">
|
||||
Workout Challenge
|
||||
</mj-text>
|
||||
<mj-button background-color="#075985" color="#fff" border-radius="24px" border="1px solid #fff" href="{{ MAIN_HOST }}/login" font-family="Helvetica, Arial, sans-serif" padding="10px 25px">
|
||||
LOGIN
|
||||
</mj-button>
|
||||
</mj-hero>
|
||||
<mj-section padding-top="0px" padding-bottom="0px" background-color="white">
|
||||
<mj-column padding-bottom="0px">
|
||||
<mj-text padding-bottom="0px">
|
||||
<p>Hi {{ first_name }},</p>
|
||||
<p>Let’s check in on your week!</p>
|
||||
<p>Keep the momentum going – keep the streak alive!</p>
|
||||
<p>Aim for 150-300 minutes of moderate or 75-150 minutes of vigorous activity weekly (or an equivalent mix) spread over at least 2 days, as recommended by the WHO.</p>
|
||||
</mj-text>
|
||||
<mj-divider border-color="#E1E8ED" border-width="2px"></mj-divider>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-section background-color="white" padding-top="5px">
|
||||
<mj-column padding-top="0px">
|
||||
<mj-text padding-bottom="0px">
|
||||
<h3>Your Streak:</h3>
|
||||
</mj-text>
|
||||
<mj-table padding-top="0px">
|
||||
<tr class="calendar">
|
||||
<th>M</th>
|
||||
<th>T</th>
|
||||
<th>W</th>
|
||||
<th>T</th>
|
||||
<th>F</th>
|
||||
<th>S</th>
|
||||
<th>S</th>
|
||||
</tr>
|
||||
<mj-raw> {% for week in calendar %} </mj-raw>
|
||||
<tr class="calendar">
|
||||
<mj-raw> {% for day in week %} </mj-raw>
|
||||
<td>
|
||||
<!--[if mso]>
|
||||
<v:oval xmlns:v="urn:schemas-microsoft-com:vml" style="height:30px;width:30px;" strokecolor="white" strokeweight="1px" fillcolor="{{ day.background_color }}">
|
||||
<v:textbox inset="0,0,0,0">
|
||||
<table width="100%" height="100%" cellpadding="0" cellspacing="0" border="0">
|
||||
<tr>
|
||||
<td align="center" valign="middle">
|
||||
<font style="font-size:14px; color:{{ day.color }};">{{ day.day }}</font>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</v:textbox>
|
||||
</v:oval>
|
||||
<![endif]-->
|
||||
|
||||
<!--[if !mso]><!-->
|
||||
<div class="day" style="background-color: {{ day.background_color }}; color: {{ day.color }};">{{ day.day }}</div>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
<mj-raw> {% endfor %} </mj-raw>
|
||||
</tr>
|
||||
<mj-raw> {% endfor %} </mj-raw>
|
||||
|
||||
<tr>
|
||||
<td colspan="7" style="padding-top:5px;padding-bottom:5px;text-align:center; font-family:Arial, sans-serif; background-color:#F3F7FA;">
|
||||
<span style="display:inline-block; width:25px; height:25px; line-height:25px; text-align:center; mso-line-height-rule:exactly; mso-table-lspace:0pt; mso-table-rspace:0pt; border:2px solid #60A8D8; border-radius:50%; background-color:#60A8D8; color:#FFFFFF; font-weight:bold; font-family:Arial, sans-serif;">
|
||||
✓
|
||||
</span>
|
||||
<span style="display:inline-block; height:25px; line-height: 25px; text-align: center;padding-left:5px; mso-table-lspace:0pt; mso-table-rspace:0pt;color: #6b7280;font-weight:bold;">{{ week_streak }} Week Streak</span>
|
||||
</td>
|
||||
|
||||
</tr>
|
||||
</mj-table>
|
||||
</mj-column>
|
||||
<mj-column>
|
||||
<mj-raw>
|
||||
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<h3>Your Personal 7-Day Goals:</h3>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;word-break:break-word;">
|
||||
|
||||
{% if goals.active_days is not None %}
|
||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="height:10px; font-size:0; line-height:0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color:#f9f9f9; border-radius:8px; padding:18px;">
|
||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="font-size:14px; line-height:16px; mso-line-height-rule:exactly; font-weight:bold; color: #6b7280;">Active Days</td>
|
||||
<td align="right" style="font-size:14px; line-height:16px; mso-line-height-rule:exactly; color: #6b7280;">{{ goals.active_days.recorded }} days <span style="font-size:10px;">/ {{ goals.active_days.target }} days</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" height="12" style="padding-top:8px; line-height:0; font-size:0;">
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" arcsize="50%" strokecolor="#e5e5e5" strokeweight="1px" style="width:{{ goals.active_days.percent_vml }}%;height:12px;" fillcolor="#4a78b5">
|
||||
<v:fill color="#4a78b5" />
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-- -->
|
||||
<div style="background:#e5e5e5; border-radius:4px; width:100%; height:12px;">
|
||||
<div style="background:#4a78b5; height:12px; border-radius:4px; width:{{ goals.active_days.percent }}%;"></div>
|
||||
</div>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if goals.minutes is not None %}
|
||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="height:15px; font-size:0; line-height:0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color:#f9f9f9; border-radius:8px; padding:18px;">
|
||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="font-size:14px; line-height:16px; mso-line-height-rule:exactly; font-weight:bold; color: #6b7280;">Exercise</td>
|
||||
<td align="right" style="font-size:14px; line-height:16px; mso-line-height-rule:exactly; color: #6b7280;">{{ goals.minutes.recorded }} min <span style="font-size:10px;">/ {{ goals.minutes.target }} min</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" height="12" style="padding-top:8px; line-height:0; font-size:0;">
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" arcsize="50%" strokecolor="#e5e5e5" strokeweight="1px" style="width:{{ goals.minutes.percent_vml }}%;height:12px;" fillcolor="#4a78b5">
|
||||
<v:fill color="#4a78b5" />
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-- -->
|
||||
<div style="background:#e5e5e5; border-radius:4px; width:100%; height:12px;">
|
||||
<div style="background:#4a78b5; height:12px; border-radius:4px; width:{{ goals.minutes.percent }}%;"></div>
|
||||
</div>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
|
||||
{% if goals.distance is not None %}
|
||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="height:15px; font-size:0; line-height:0;"> </td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td style="background-color:#f9f9f9; border-radius:8px; padding:18px;">
|
||||
<table cellpadding="0" cellspacing="0" border="0" width="100%">
|
||||
<tr>
|
||||
<td style="font-size:14px; line-height:16px; mso-line-height-rule:exactly; font-weight:bold; color: #6b7280;">Distance</td>
|
||||
<td align="right" style="font-size:14px; line-height:16px; mso-line-height-rule:exactly; color: #6b7280;">{{ goals.distance.recorded }} km <span style="font-size:10px;">/ {{ goals.distance.target }} km</span></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="2" height="12" style="padding-top:8px; line-height:0; font-size:0;">
|
||||
<!--[if mso]>
|
||||
<v:roundrect xmlns:v="urn:schemas-microsoft-com:vml" arcsize="50%" strokecolor="#e5e5e5" strokeweight="1px" style="width:{{ goals.distance.percent_vml }}%;height:12px;" fillcolor="#4a78b5">
|
||||
<v:fill color="#4a78b5" />
|
||||
</v:roundrect>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-- -->
|
||||
<div style="background:#e5e5e5; border-radius:4px; width:100%; height:12px;">
|
||||
<div style="background:#4a78b5; height:12px; border-radius:4px; width:{{ goals.distance.percent }}%;"></div>
|
||||
</div>
|
||||
<!--<![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</mj-raw>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-raw> {% if openai_quote is not None %} </mj-raw>
|
||||
<!-- AI Health Quote -->
|
||||
<mj-section background-color="#075985" vertical-align="middle">
|
||||
<mj-column width="100%" vertical-align="middle">
|
||||
<mj-text align="center" color="#ffffff" font-size="16px" padding-bottom="10px" padding-top="20px">{{ openai_quote }}</mj-text>
|
||||
<mj-divider border-color="#ffffff" border-style="solid" border-width="1px" padding-left="100px" padding-right="100px" padding-bottom="10px"></mj-divider>
|
||||
<mj-text align="center" color="#e5e7eb" font-size="11px" padding-bottom="0px" padding-top="0px">
|
||||
<p>by OpenAI's ChatGPT 4o</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-raw> {% endif %} </mj-raw>
|
||||
|
||||
<!-- Final remarks -->
|
||||
<mj-section padding-top="0px" padding-bottom="0px" background-color="white">
|
||||
<mj-column>
|
||||
<mj-raw> {% if openai_quote is None %} </mj-raw>
|
||||
<mj-divider border-color="#E1E8ED" border-width="2px"></mj-divider>
|
||||
<mj-raw> {% endif %} </mj-raw>
|
||||
<mj-text padding-top="20px">Login to see more detailed analyses, charts, and breakdowns:</mj-text>
|
||||
<mj-button align="center" background-color="#075985" color="#ffffff" border-radius="24px" href="{{ MAIN_HOST }}/login" font-family="Helvetica, Arial, sans-serif, Helvetica, Arial, sans-serif" padding-bottom="2px" padding-top="5px">LOGIN</mj-button>
|
||||
<mj-text padding-bottom="10px" padding-top="0px">
|
||||
<p>Enjoy the Competition.</p>
|
||||
<p>Good luck!</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<!-- Footnote -->
|
||||
<mj-section full-width="full-width">
|
||||
<mj-column width="100%" vertical-align="middle">
|
||||
<mj-text align="center" color="#9a9ea6" font-size="11px" padding-bottom="0px" padding-top="0">
|
||||
<span class="apple-link">Workout Challenge</span>
|
||||
<br> Any issues? <a href="mailto:{{ EMAIL_REPLY_TO }}">Send us an Email</a>. <br> See the <a href="https://github.com/vanalmsick/workout_challenge">Source Code</a>.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
</mj-body>
|
||||
</mjml>
|
||||
375
src-backend/custom_user/templates/email_welcome.html
Normal file
375
src-backend/custom_user/templates/email_welcome.html
Normal file
|
|
@ -0,0 +1,375 @@
|
|||
<!doctype html>
|
||||
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:v="urn:schemas-microsoft-com:vml" xmlns:o="urn:schemas-microsoft-com:office:office">
|
||||
|
||||
<head>
|
||||
<title>
|
||||
</title>
|
||||
<!--[if !mso]><!-->
|
||||
<meta http-equiv="X-UA-Compatible" content="IE=edge">
|
||||
<!--<![endif]-->
|
||||
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<style type="text/css">
|
||||
#outlook a {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
-ms-text-size-adjust: 100%;
|
||||
}
|
||||
|
||||
table,
|
||||
td {
|
||||
border-collapse: collapse;
|
||||
mso-table-lspace: 0pt;
|
||||
mso-table-rspace: 0pt;
|
||||
}
|
||||
|
||||
img {
|
||||
border: 0;
|
||||
height: auto;
|
||||
line-height: 100%;
|
||||
outline: none;
|
||||
text-decoration: none;
|
||||
-ms-interpolation-mode: bicubic;
|
||||
}
|
||||
|
||||
p {
|
||||
display: block;
|
||||
margin: 13px 0;
|
||||
}
|
||||
</style>
|
||||
<!--[if mso]>
|
||||
<noscript>
|
||||
<xml>
|
||||
<o:OfficeDocumentSettings>
|
||||
<o:AllowPNG/>
|
||||
<o:PixelsPerInch>96</o:PixelsPerInch>
|
||||
</o:OfficeDocumentSettings>
|
||||
</xml>
|
||||
</noscript>
|
||||
<![endif]-->
|
||||
<!--[if lte mso 11]>
|
||||
<style type="text/css">
|
||||
.mj-outlook-group-fix { width:100% !important; }
|
||||
</style>
|
||||
<![endif]-->
|
||||
<!--[if !mso]><!-->
|
||||
<link href="https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700" rel="stylesheet" type="text/css">
|
||||
<style type="text/css">
|
||||
@import url(https://fonts.googleapis.com/css?family=Ubuntu:300,400,500,700);
|
||||
</style>
|
||||
<!--<![endif]-->
|
||||
<style type="text/css">
|
||||
@media only screen and (min-width:480px) {
|
||||
.mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.mj-column-per-50 {
|
||||
width: 50% !important;
|
||||
max-width: 50%;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
<style media="screen and (min-width:480px)">
|
||||
.moz-text-html .mj-column-per-100 {
|
||||
width: 100% !important;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.moz-text-html .mj-column-per-50 {
|
||||
width: 50% !important;
|
||||
max-width: 50%;
|
||||
}
|
||||
</style>
|
||||
<style type="text/css">
|
||||
</style>
|
||||
<style type="text/css">
|
||||
</style>
|
||||
</head>
|
||||
|
||||
<body style="word-spacing:normal;background-color:#E1E8ED;">
|
||||
<div style="display:none;font-size:1px;color:#ffffff;line-height:1px;max-height:0px;max-width:0px;opacity:0;overflow:hidden;"> Thanks for signing up to the Workout Challenge! Bookmark the page in your browser. How does the competition work? How to earn activity points? You'll find out now! </div>
|
||||
<div style="background-color:#E1E8ED;">
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0;font-size:0;mso-line-height-rule:exactly;"><v:image style="border:0;mso-position-horizontal:center;position:absolute;top:0;width:600px;z-index:-3;" src="{{ MAIN_HOST }}/running_email.jpg" xmlns:v="urn:schemas-microsoft-com:vml" /><![endif]-->
|
||||
<div style="margin:0 auto;max-width:600px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr style="vertical-align:top;">
|
||||
<td background="{{ MAIN_HOST }}/running_email.jpg" style="background:#075985 url('{{ MAIN_HOST }}/running_email.jpg') no-repeat center center / cover;background-position:center center;background-repeat:no-repeat;padding:0px;vertical-align:top;" height="200">
|
||||
<!--[if mso | IE]><table border="0" cellpadding="0" cellspacing="0" style="width:600px;" width="600" ><tr><td style=""><![endif]-->
|
||||
<div class="mj-hero-content" style="margin:0px auto;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;margin:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;margin:0px;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" class="no-invert" style="font-size:0px;padding:10px 25px;padding-top:45px;padding-bottom:10px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:30px;line-height:1.2;text-align:center;color:#ffffff;">Workout Challenge</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#075985" role="presentation" style="border:1px solid #fff;border-radius:24px;cursor:auto;mso-padding-alt:10px 25px;background:#075985;" valign="middle">
|
||||
<a href="{{ MAIN_HOST }}/login" style="display:inline-block;background:#075985;color:#ffffff;font-family:Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:24px;" target="_blank"> LOGIN </a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="white" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:white;background-color:white;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:white;background-color:white;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="vertical-align:top;padding-bottom:0px;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<p>Hi {{ first_name }},</p>
|
||||
<p>Thanks for signing up to the <b>Workout Challenge</b>!</p>
|
||||
<p><b>Bookmark the page:</b> <a href="{{ MAIN_HOST }}">{{ MAIN_HOST }}</a></p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<p style="border-top:solid 2px #E1E8ED;font-size:1px;margin:0px auto;width:100%;">
|
||||
</p>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #E1E8ED;font-size:1px;margin:0px auto;width:550px;" role="presentation" width="550px" ><tr><td style="height:0;line-height:0;">
|
||||
</td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
<!-- How-to Section -->
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="white" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:white;background-color:white;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:white;background-color:white;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:10px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:300px;" ><![endif]-->
|
||||
<div class="mj-column-per-50 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:15px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<h3>How does it work?</h3>
|
||||
<ul>
|
||||
<li>Join a friend's competition or create your own.</li>
|
||||
<li>For automatic workout import, link your Strava account to the Workout Challenge.</li>
|
||||
<li>Before a competition starts, join a team.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td><td class="" style="vertical-align:top;width:300px;" ><![endif]-->
|
||||
<div class="mj-column-per-50 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:15px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<h3>How to earn activity points?</h3>
|
||||
<ul>
|
||||
<li>Each competition has its own activity goals.</li>
|
||||
<li>You earn <b>1 point for every 1%</b> progress toward a goal.</li>
|
||||
<li>For example, if the goal is 100 minutes of exercise and you work out 50 minutes, you earn 50 points.</li>
|
||||
<li><i>Note:</i> Some goals have <b>minimum or maximum limits</b>. Activities above/below these limits won’t earn you points and are marked with an asterisk (*).</li>
|
||||
<li>Hover over a goal to view its limits, or over the asterisk for more details.</li>
|
||||
</ul>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]--> {% if link_strava_note %}
|
||||
<!-- Link Strava -->
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="#075985" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:#075985;background-color:#075985;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:#075985;background-color:#075985;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:middle;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:20px;padding-bottom:10px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:18px;line-height:1.2;text-align:center;color:#ffffff;">Skip the hassle of manual logging – connect Strava for automatic workout imports!</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-right:100px;padding-bottom:20px;padding-left:100px;word-break:break-word;">
|
||||
<p style="border-top:solid 1px #ffffff;font-size:1px;margin:0px auto;width:100%;">
|
||||
</p>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 1px #ffffff;font-size:1px;margin:0px auto;width:400px;" role="presentation" width="400px" ><tr><td style="height:0;line-height:0;">
|
||||
</td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:25px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:11px;line-height:1.2;text-align:center;color:#e5e7eb;">
|
||||
<p>Every day at 4 AM, your workouts are automatically imported, only syncing the most essential data with Strava to respect your privacy:</p>
|
||||
<span>• Sport type</span><br />
|
||||
<span>• Start time & duration</span><br />
|
||||
<span>• Workout id</span><br />
|
||||
<span>• Distance, kcal, kj, avg. watt</span>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td align="center" vertical-align="middle" style="font-size:0px;padding:10px 25px;padding-top:0px;padding-bottom:5px;word-break:break-word;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="border-collapse:separate;line-height:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" bgcolor="#ffffff" role="presentation" style="border:none;border-radius:24px;cursor:auto;mso-padding-alt:10px 25px;background:#ffffff;" valign="middle">
|
||||
<a href="{{ MAIN_HOST }}/strava/link" style="display:inline-block;background:#ffffff;color:#075985;font-family:Helvetica, Arial, sans-serif, Helvetica, Arial, sans-serif;font-size:13px;font-weight:normal;line-height:120%;margin:0;text-decoration:none;text-transform:none;padding:10px 25px;mso-padding-alt:0px;border-radius:24px;" target="_blank"> Link Strava </a>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]--> {% endif %}
|
||||
<!-- Final remarks -->
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" bgcolor="white" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="background:white;background-color:white;margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="background:white;background-color:white;width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;padding-bottom:0px;padding-top:0px;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:top;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:top;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:top;" width="100%">
|
||||
<tbody> {% if not link_strava_note %} <tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;word-break:break-word;">
|
||||
<p style="border-top:solid 2px #E1E8ED;font-size:1px;margin:0px auto;width:100%;">
|
||||
</p>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" style="border-top:solid 2px #E1E8ED;font-size:1px;margin:0px auto;width:550px;" role="presentation" width="550px" ><tr><td style="height:0;line-height:0;">
|
||||
</td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr> {% endif %} <tr>
|
||||
<td align="left" style="font-size:0px;padding:10px 25px;padding-top:5px;padding-bottom:10px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:13px;line-height:1.2;text-align:left;color:#000000;">
|
||||
<p>Enjoy the Competition.</p>
|
||||
<p>Good luck!</p>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
<!-- Footnote -->
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td>
|
||||
<!--[if mso | IE]><table align="center" border="0" cellpadding="0" cellspacing="0" class="" role="presentation" style="width:600px;" width="600" ><tr><td style="line-height:0px;font-size:0px;mso-line-height-rule:exactly;"><![endif]-->
|
||||
<div style="margin:0px auto;max-width:600px;">
|
||||
<table align="center" border="0" cellpadding="0" cellspacing="0" role="presentation" style="width:100%;">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td style="direction:ltr;font-size:0px;padding:20px 0;text-align:center;">
|
||||
<!--[if mso | IE]><table role="presentation" border="0" cellpadding="0" cellspacing="0"><tr><td class="" style="vertical-align:middle;width:600px;" ><![endif]-->
|
||||
<div class="mj-column-per-100 mj-outlook-group-fix" style="font-size:0px;text-align:left;direction:ltr;display:inline-block;vertical-align:middle;width:100%;">
|
||||
<table border="0" cellpadding="0" cellspacing="0" role="presentation" style="vertical-align:middle;" width="100%">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td align="center" style="font-size:0px;padding:10px 25px;padding-top:0;padding-bottom:0px;word-break:break-word;">
|
||||
<div style="font-family:Ubuntu, Helvetica, Arial, sans-serif;font-size:11px;line-height:1.2;text-align:center;color:#9a9ea6;"><span class="apple-link">Workout Challenge</span>
|
||||
<br> Any issues? <a href="mailto:{{ EMAIL_REPLY_TO }}">Send us an Email</a>. <br> See the <a href="https://github.com/vanalmsick/workout_challenge">Source Code</a>.
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<!--[if mso | IE]></td></tr></table><![endif]-->
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
99
src-backend/custom_user/templates/email_welcome.mjml
Normal file
99
src-backend/custom_user/templates/email_welcome.mjml
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
<mjml>
|
||||
<!-- Written with https://mjml.io/ -->
|
||||
<mj-head>
|
||||
<mj-preview>Thanks for signing up to the Workout Challenge! Bookmark the page in your browser. How does the competition work? How to earn activity points? You'll find out now!</mj-preview>
|
||||
<mj-attributes>
|
||||
<mj-text line-height="1.2" />
|
||||
</mj-attributes>
|
||||
</mj-head>
|
||||
<mj-body background-color="#E1E8ED">
|
||||
<mj-hero mode="fixed-height" height="200px" background-url="{{ MAIN_HOST }}/running_email.jpg" background-size="cover" background-repeat="no-repeat" background-color="#075985">
|
||||
<mj-text align="center" font-size="30px" color="#ffffff" padding-bottom="10px" padding-top="45px" css-class="no-invert">
|
||||
Workout Challenge
|
||||
</mj-text>
|
||||
<mj-button background-color="#075985" color="#fff" border-radius="24px" border="1px solid #fff" href="{{ MAIN_HOST }}/login" font-family="Helvetica, Arial, sans-serif" padding="10px 25px">
|
||||
LOGIN
|
||||
</mj-button>
|
||||
</mj-hero>
|
||||
|
||||
<mj-section padding-top="0px" padding-bottom="0px" background-color="white">
|
||||
<mj-column padding-bottom="0px">
|
||||
<mj-text padding-bottom="0px">
|
||||
<p>Hi {{ first_name }},</p>
|
||||
<p>Thanks for signing up to the <b>Workout Challenge</b>!</p>
|
||||
<p><b>Bookmark the page:</b> <a href="{{ MAIN_HOST }}">{{ MAIN_HOST }}</a></p>
|
||||
</mj-text>
|
||||
<mj-divider border-color="#E1E8ED" border-width="2px"></mj-divider>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<!-- How-to Section -->
|
||||
<mj-section padding-top="10px" padding-bottom="0px" background-color="white">
|
||||
<mj-column>
|
||||
<mj-text padding-top="0px" padding-bottom="15px">
|
||||
<h3>How does it work?</h3>
|
||||
<ul>
|
||||
<li>Join a friend's competition or create your own.</li>
|
||||
<li>For automatic workout import, link your Strava account to the Workout Challenge.</li>
|
||||
<li>Before a competition starts, join a team.</li>
|
||||
</ul>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
<mj-column>
|
||||
<mj-text padding-top="0px" padding-bottom="15px">
|
||||
<h3>How to earn activity points?</h3>
|
||||
<ul>
|
||||
<li>Each competition has its own activity goals.</li>
|
||||
<li>You earn <b>1 point for every 1%</b> progress toward a goal.</li>
|
||||
<li>For example, if the goal is 100 minutes of exercise and you work out 50 minutes, you earn 50 points.</li>
|
||||
<li><i>Note:</i> Some goals have <b>minimum or maximum limits</b>. Activities above/below these limits won’t earn you points and are marked with an asterisk (*).</li>
|
||||
<li>Hover over a goal to view its limits, or over the asterisk for more details.</li>
|
||||
</ul>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<mj-raw> {% if link_strava_note %} </mj-raw>
|
||||
<!-- Link Strava -->
|
||||
<mj-section background-color="#075985" vertical-align="middle">
|
||||
<mj-column width="100%" vertical-align="middle">
|
||||
<mj-text align="center" color="#ffffff" font-size="18px" padding-bottom="10px" padding-top="20px">Skip the hassle of manual logging – connect Strava for automatic workout imports!</mj-text>
|
||||
<mj-divider border-color="#fff" border-style="solid" border-width="1px" padding-left="100px" padding-right="100px" padding-bottom="20px"></mj-divider>
|
||||
<mj-text align="center" color="#e5e7eb" font-size="11px" padding-bottom="25px" padding-top="0px">
|
||||
<p>Every day at 4 AM, your workouts are automatically imported, only syncing the most essential data with Strava to respect your privacy:</p>
|
||||
<span>• Sport type</span><br/>
|
||||
<span>• Start time & duration</span><br/>
|
||||
<span>• Workout id</span><br/>
|
||||
<span>• Distance, kcal, kj, avg. watt</span>
|
||||
</mj-text>
|
||||
<mj-button align="center" background-color="#ffffff" color="#075985" border-radius="24px" href="{{ MAIN_HOST }}/strava/link" font-family="Helvetica, Arial, sans-serif, Helvetica, Arial, sans-serif" padding-bottom="5px" padding-top="0px">Link Strava</mj-button>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
<mj-raw> {% endif %} </mj-raw>
|
||||
|
||||
<!-- Final remarks -->
|
||||
<mj-section padding-top="0px" padding-bottom="0px" background-color="white">
|
||||
<mj-column>
|
||||
<mj-raw> {% if not link_strava_note %} </mj-raw>
|
||||
<mj-divider border-color="#E1E8ED" border-width="2px"></mj-divider>
|
||||
<mj-raw> {% endif %} </mj-raw>
|
||||
<mj-text padding-bottom="10px" padding-top="5px">
|
||||
<p>Enjoy the Competition.</p>
|
||||
<p>Good luck!</p>
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
<!-- Footnote -->
|
||||
</mj-section>
|
||||
<mj-section full-width="full-width">
|
||||
<mj-column width="100%" vertical-align="middle">
|
||||
<mj-text align="center" color="#9a9ea6" font-size="11px" padding-bottom="0px" padding-top="0">
|
||||
<span class="apple-link">Workout Challenge</span>
|
||||
<br> Any issues? <a href="mailto:{{ EMAIL_REPLY_TO }}">Send us an Email</a>. <br> See the <a href="https://github.com/vanalmsick/workout_challenge">Source Code</a>.
|
||||
</mj-text>
|
||||
</mj-column>
|
||||
</mj-section>
|
||||
|
||||
</mj-body>
|
||||
</mjml>
|
||||
3
src-backend/custom_user/tests.py
Normal file
3
src-backend/custom_user/tests.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from django.test import TestCase
|
||||
|
||||
# Create your tests here.
|
||||
181
src-backend/custom_user/views.py
Normal file
181
src-backend/custom_user/views.py
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import time, datetime
|
||||
import requests
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.permissions import BasePermission, IsAdminUser, SAFE_METHODS, AllowAny
|
||||
from rest_framework.permissions import IsAuthenticated
|
||||
from django.db.models import Q
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework.response import Response
|
||||
from rest_framework import status
|
||||
from celery.exceptions import TimeoutError
|
||||
from django.utils import timezone
|
||||
from django_filters.rest_framework import DjangoFilterBackend
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.conf import settings
|
||||
from django.core.cache import cache
|
||||
|
||||
from .serializers import PasswordResetSerializer, PasswordResetConfirmSerializer
|
||||
from .models import CustomUser
|
||||
from .serializers import CustomUserSerializer
|
||||
from .filters import CustomUserFilter
|
||||
from .strava import sync_strava
|
||||
|
||||
class IsOwnerOrReadOnly(BasePermission):
|
||||
""" Permission class to only allow admins and owner to edit or delete entry """
|
||||
def has_permission(self, request, view):
|
||||
# Only authenticated users
|
||||
if request.user.is_authenticated:
|
||||
return True
|
||||
return False
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
# Read requests always allowed
|
||||
if request.method in SAFE_METHODS:
|
||||
return True # allow GET, HEAD, OPTIONS (GET is filtered at viweset level to only show allowed entries)
|
||||
# Only workout user can edit workout
|
||||
if hasattr(obj, 'user') and obj.user == request.user:
|
||||
return True
|
||||
# Only owner of competition can modify
|
||||
elif hasattr(obj, 'owner') and obj.owner == request.user:
|
||||
return True
|
||||
# Only owner can modify goals and awards
|
||||
elif hasattr(obj, 'competition') and hasattr(obj.competition, 'owner') and obj.competition.owner == request.user:
|
||||
return True
|
||||
# If admin allow all requests
|
||||
if bool(request.user and request.user.is_staff):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
class UserPermissionClass(BasePermission):
|
||||
""" Allow unauthenticated users to POST data - i.e. for registration """
|
||||
def has_permission(self, request, view):
|
||||
# Only create new requsts - i.e. POST
|
||||
if request.method in ('POST', 'OPTIONS'):
|
||||
return True
|
||||
return request.user.is_authenticated
|
||||
|
||||
def has_object_permission(self, request, view, obj):
|
||||
return obj.pk == request.user.pk
|
||||
|
||||
|
||||
class CustomUserViewSet(viewsets.ModelViewSet):
|
||||
#queryset = Competition.objects.all()
|
||||
serializer_class = CustomUserSerializer
|
||||
|
||||
filter_backends = [DjangoFilterBackend]
|
||||
filterset_class = CustomUserFilter
|
||||
|
||||
permission_classes = [UserPermissionClass]
|
||||
|
||||
def get_queryset(self):
|
||||
# return all competitions the user is owner of or a participant of
|
||||
#time.sleep(3) # throttle for testing
|
||||
return CustomUser.objects.filter(Q(pk=self.request.user.pk) | Q(my_competitions__in=self.request.user.my_competitions.all())).distinct().order_by('username', 'id')
|
||||
|
||||
def get_object(self):
|
||||
lookup_value = self.kwargs.get(self.lookup_field)
|
||||
|
||||
# Modify filter if I ask for myself instead of the id number
|
||||
if str(lookup_value).lower() in ['me', 'my', 'myself', 'i']:
|
||||
lookup_value = self.request.user.id
|
||||
|
||||
return get_object_or_404(self.get_queryset(), pk=lookup_value)
|
||||
|
||||
|
||||
class PasswordResetView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
serializer = PasswordResetSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save(request=request)
|
||||
return Response({"detail": "Password reset e-mail sent."})
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class PasswordResetConfirmView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
|
||||
def post(self, request):
|
||||
serializer = PasswordResetConfirmSerializer(data=request.data)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response({"detail": "Password has been reset."})
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class LinkStravaView(APIView):
|
||||
""" API post view for users to link with Strava. """
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request, code):
|
||||
user = request.user
|
||||
client_id = settings.STRAVA_CLIENT_ID
|
||||
client_secret = settings.STRAVA_CLIENT_SECRET
|
||||
|
||||
if client_id == 1234321 or client_secret == "ReplaceWithClientSecret":
|
||||
return Response({"message": "Sever configuration error - STRAVA_CLIENT_ID and/or STRAVA_CLIENT_SECRET are not set."}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
|
||||
|
||||
response = requests.post(
|
||||
url='https://www.strava.com/oauth/token',
|
||||
data={
|
||||
'client_id': client_id,
|
||||
'client_secret': client_secret,
|
||||
'code': code,
|
||||
'grant_type': 'authorization_code'
|
||||
}
|
||||
)
|
||||
|
||||
if response.ok is False:
|
||||
return Response({"message": "Invalid Strava linkage code"}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
strava_tokens = response.json()
|
||||
setattr(user, 'strava_refresh_token', strava_tokens.get('refresh_token', None))
|
||||
setattr(user, 'strava_athlete_id', strava_tokens.get('athlete', {}).get('id', None))
|
||||
user.save()
|
||||
|
||||
cache.set(f"strava_access_token_{user.id}", strava_tokens.get('access_token', None), int(strava_tokens.get('expires_in', 21600)) - 60)
|
||||
try:
|
||||
running_task = sync_strava.delay(user__id=user.id, start_datetime=datetime.datetime.now() - datetime.timedelta(days=43))
|
||||
try:
|
||||
running_task.get(timeout=100)
|
||||
except TimeoutError:
|
||||
print(f"Strava sync task is still running ({running_task.id}). Don't let the user wait so long.")
|
||||
except requests.exceptions.HTTPError as err:
|
||||
if '401 Client Error: Unauthorized' in str(err):
|
||||
return Response({'message': 'Access to activities denied by Strava. Not sufficient permissions to download activities.'}, status=status.HTTP_403_FORBIDDEN)
|
||||
else:
|
||||
raise Response(err.response.json(), status=err.response.status_code)
|
||||
|
||||
return Response({"message": "Successfully linked Strava."}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class UnlinkStravaView(APIView):
|
||||
""" API post view for users to unlink Strava. """
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def post(self, request):
|
||||
user = request.user
|
||||
setattr(user, 'strava_refresh_token', None)
|
||||
setattr(user, 'strava_athlete_id', None)
|
||||
user.save()
|
||||
|
||||
return Response({"message": "Successfully unlinked Strava."}, status=status.HTTP_200_OK)
|
||||
|
||||
|
||||
class SyncStravaView(APIView):
|
||||
""" API get view for users to sync Strava. """
|
||||
permission_classes = [IsAuthenticated]
|
||||
|
||||
def get(self, request):
|
||||
user = request.user
|
||||
|
||||
if user.strava_refresh_token is None or user.strava_refresh_token == '':
|
||||
return Response({"message": "Strava is not linked."}, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
if user.strava_last_synced_at is None or user.strava_last_synced_at == '' or user.strava_last_synced_at < (timezone.now() - datetime.timedelta(minutes=59)):
|
||||
sync_strava(user__id=user.id)
|
||||
return Response({"message": f"Successfully synced Strava."}, status=status.HTTP_200_OK)
|
||||
|
||||
return Response({"message": "Too many requests! You can only request a Strava sync every 60 minutes."}, status=status.HTTP_429_TOO_MANY_REQUESTS)
|
||||
31
src-backend/manage.py
Executable file
31
src-backend/manage.py
Executable file
|
|
@ -0,0 +1,31 @@
|
|||
#!/usr/bin/env python
|
||||
"""Django's command-line utility for administrative tasks."""
|
||||
import os
|
||||
import sys
|
||||
|
||||
|
||||
def make_sure_paths_exist():
|
||||
""" Make sure data folder paths exist for the database and/or migrations. """
|
||||
for path in ['./data', './data/db_migrations', './data/db_migrations/competition', './data/db_migrations/workouts', './data/db_migrations/custom_user']:
|
||||
if not os.path.exists(path):
|
||||
os.makedirs(path)
|
||||
open(path + '/__init__.py', "a").close()
|
||||
|
||||
|
||||
def main():
|
||||
"""Run administrative tasks."""
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'workout_challenge.settings')
|
||||
try:
|
||||
from django.core.management import execute_from_command_line
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"Couldn't import Django. Are you sure it's installed and "
|
||||
"available on your PYTHONPATH environment variable? Did you "
|
||||
"forget to activate a virtual environment?"
|
||||
) from exc
|
||||
execute_from_command_line(sys.argv)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
make_sure_paths_exist()
|
||||
main()
|
||||
18
src-backend/requirements.txt
Normal file
18
src-backend/requirements.txt
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
requests
|
||||
pytz
|
||||
qrcode[pil]
|
||||
django
|
||||
djangorestframework
|
||||
djangorestframework-simplejwt
|
||||
django-cors-headers
|
||||
django-filter
|
||||
psycopg2-binary
|
||||
celery
|
||||
flower
|
||||
redis
|
||||
django-redis
|
||||
django-celery-beat
|
||||
sentry-sdk[django]
|
||||
sentry-sdk[celery]
|
||||
openai>=1.0.0
|
||||
gevent
|
||||
3
src-backend/workout_challenge/__init__.py
Normal file
3
src-backend/workout_challenge/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from .celery import app as celery_app
|
||||
|
||||
__all__ = ("celery_app",)
|
||||
16
src-backend/workout_challenge/asgi.py
Normal file
16
src-backend/workout_challenge/asgi.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
ASGI config for workout_challenge project.
|
||||
|
||||
It exposes the ASGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.2/howto/deployment/asgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.asgi import get_asgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'workout_challenge.settings')
|
||||
|
||||
application = get_asgi_application()
|
||||
77
src-backend/workout_challenge/celery.py
Normal file
77
src-backend/workout_challenge/celery.py
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
"""Celery task config"""
|
||||
|
||||
import os
|
||||
|
||||
from celery import Celery
|
||||
from celery.schedules import crontab
|
||||
|
||||
# Set the default Django settings module for the 'celery' program.
|
||||
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "workout_challenge.settings")
|
||||
|
||||
app = Celery("workout_challenge")
|
||||
|
||||
# Using a string here means the worker doesn't have to serialize
|
||||
# the configuration object to child processes.
|
||||
# - namespace='CELERY' means all celery-related configuration keys
|
||||
# should have a `CELERY_` prefix.
|
||||
app.config_from_object("django.conf:settings", namespace="CELERY")
|
||||
|
||||
# Load task modules from all registered Django apps.
|
||||
app.autodiscover_tasks()
|
||||
|
||||
app.conf.beat_schedule = {
|
||||
# every morning import users strava workouts
|
||||
"strava_sync": {
|
||||
"task": "custom_user.strava.daily_strava_sync",
|
||||
"schedule": crontab(minute="44", hour="4"),
|
||||
"args": (),
|
||||
},
|
||||
# not needed - just fallback - do all pending point recalc tasks in the morning
|
||||
"point_recal": {
|
||||
"task": "custom_user.point_recalc.recalc_points",
|
||||
"schedule": crontab(minute="55", hour="5"),
|
||||
"args": (),
|
||||
},
|
||||
# every Monday morning ask people who didn't connect Strava to please log their workouts
|
||||
"send_all_log_workouts_email": {
|
||||
"task": "custom_user.emails.celery_emails.send_all_log_workouts_email",
|
||||
"schedule": crontab(day_of_week="1", minute="5", hour="9"),
|
||||
"args": (),
|
||||
},
|
||||
# every Monday afternoon send competition leaderboards out
|
||||
"send_all_leaderboard_emails": {
|
||||
"task": "custom_user.emails.celery_emails.send_all_leaderboard_emails",
|
||||
"schedule": crontab(day_of_week="1", minute="5", hour="15"),
|
||||
"args": (),
|
||||
},
|
||||
# every Thursday afternoon send weekly check-ins out
|
||||
"send_all_weekly_emails": {
|
||||
"task": "custom_user.emails.celery_emails.send_all_weekly_emails",
|
||||
"schedule": crontab(day_of_week="4", minute="5", hour="15"),
|
||||
"args": (),
|
||||
},
|
||||
# every day at noon send start competition email
|
||||
"send_all_competition_start_email": {
|
||||
"task": "custom_user.emails.celery_emails.send_all_competition_start_email",
|
||||
"schedule": crontab(minute="5", hour="12"),
|
||||
"args": (),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def is_task_already_executing(task_name: str) -> bool:
|
||||
"""Returns whether the task with given task_name is already being executed.
|
||||
|
||||
Args:
|
||||
task_name: Name of the task to check if it is running currently.
|
||||
Returns: A boolean indicating whether the task with the given task name is
|
||||
running currently.
|
||||
"""
|
||||
active_tasks = app.control.inspect().active()
|
||||
task_count = 0
|
||||
for worker, running_tasks in active_tasks.items():
|
||||
for task in running_tasks:
|
||||
if task["name"] == task_name:
|
||||
task_count += 1
|
||||
|
||||
return task_count > 1
|
||||
261
src-backend/workout_challenge/settings.py
Normal file
261
src-backend/workout_challenge/settings.py
Normal file
|
|
@ -0,0 +1,261 @@
|
|||
"""
|
||||
Django settings for workout_challenge project.
|
||||
|
||||
Generated by 'django-admin startproject' using Django 4.2.20.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.2/topics/settings/
|
||||
|
||||
For the full list of settings and their values, see
|
||||
https://docs.djangoproject.com/en/4.2/ref/settings/
|
||||
"""
|
||||
|
||||
import os
|
||||
import datetime, pytz
|
||||
from pathlib import Path
|
||||
from urllib.parse import urlparse
|
||||
import sentry_sdk
|
||||
from sentry_sdk.integrations.django import DjangoIntegration
|
||||
from sentry_sdk.integrations.celery import CeleryIntegration
|
||||
|
||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||
BASE_DIR = Path(__file__).resolve().parent.parent
|
||||
DATA_DIR = BASE_DIR / "data"
|
||||
QR_CODE_PATH = BASE_DIR.parent / "src-frontend" / "public"
|
||||
|
||||
|
||||
# Quick-start development settings - unsuitable for production
|
||||
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
|
||||
|
||||
# SECURITY WARNING: keep the secret key used in production secret!
|
||||
SECRET_KEY = os.environ.get("SECRET_KEY", 'django-insecure-y4cob-qij5d!!h6^oy8bt_xqtuo%3s$w(^=7wq%9w%ckd(--9t')
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = os.environ.get("DEBUG", "false").lower() == "true"
|
||||
print(f'Debug modus is turned {"on" if DEBUG else "off"}')
|
||||
|
||||
MAIN_HOST = os.environ.get("MAIN_HOST", "http://localhost")
|
||||
HOSTS = os.environ.get("HOSTS", "http://localhost,http://127.0.0.1").split(",")
|
||||
CSRF_TRUSTED_ORIGINS = HOSTS
|
||||
ALLOWED_HOSTS = [urlparse(url).netloc for url in HOSTS]
|
||||
CORS_ALLOWED_ORIGINS = HOSTS
|
||||
CORS_ALLOW_ALL_ORIGINS = True if DEBUG else False
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
|
||||
# Application definition
|
||||
|
||||
INSTALLED_APPS = [
|
||||
'django.contrib.admin',
|
||||
'django.contrib.auth',
|
||||
'django.contrib.contenttypes',
|
||||
'django.contrib.sessions',
|
||||
'django.contrib.messages',
|
||||
'django.contrib.staticfiles',
|
||||
'corsheaders',
|
||||
'rest_framework',
|
||||
'django_filters',
|
||||
'celery',
|
||||
'django_celery_beat',
|
||||
'competition',
|
||||
'workouts',
|
||||
'custom_user',
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
'django.middleware.security.SecurityMiddleware',
|
||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
||||
'django.middleware.common.CommonMiddleware',
|
||||
'django.middleware.csrf.CsrfViewMiddleware',
|
||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
||||
'django.contrib.messages.middleware.MessageMiddleware',
|
||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
||||
]
|
||||
|
||||
AUTH_USER_MODEL = "custom_user.CustomUser"
|
||||
ROOT_URLCONF = 'workout_challenge.urls'
|
||||
|
||||
|
||||
MIGRATION_MODULES = {
|
||||
"competition": "data.db_migrations.competition",
|
||||
"workouts": "data.db_migrations.workouts",
|
||||
"custom_user": "data.db_migrations.custom_user",
|
||||
# "django_celery_beat": "data.db_migrations.django_celery_beat",
|
||||
# "django_celery_beat_periodictask": "data.db_migrations.django_celery_beat_periodictask",
|
||||
# "sessions": "data.db_migrations.sessions",
|
||||
# "auth": "data.db_migrations.auth",
|
||||
# "authtoken": "data.db_migrations.authtoken",
|
||||
# "admin": "data.db_migrations.admin",
|
||||
# "contenttypes": "data.db_migrations.contenttypes",
|
||||
}
|
||||
|
||||
|
||||
TEMPLATES = [
|
||||
{
|
||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
||||
'DIRS': [],
|
||||
'APP_DIRS': True,
|
||||
'OPTIONS': {
|
||||
'context_processors': [
|
||||
'django.template.context_processors.debug',
|
||||
'django.template.context_processors.request',
|
||||
'django.contrib.auth.context_processors.auth',
|
||||
'django.contrib.messages.context_processors.messages',
|
||||
],
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
WSGI_APPLICATION = 'workout_challenge.wsgi.application'
|
||||
|
||||
|
||||
# Database
|
||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
|
||||
|
||||
DATABASES = {
|
||||
"default": {
|
||||
"ENGINE": "django.db.backends.sqlite3",
|
||||
"NAME": DATA_DIR / 'db.sqlite3',
|
||||
"OPTIONS": {
|
||||
"timeout": 20, # seconds
|
||||
},
|
||||
}
|
||||
if os.environ.get("POSTGRES_HOST", None) is None
|
||||
else {
|
||||
"ENGINE": "django.db.backends.postgresql",
|
||||
"NAME": os.environ.get("POSTGRES_DB", "postgres"),
|
||||
"USER": os.environ.get("POSTGRES_USER", "postgres"),
|
||||
"PASSWORD": os.environ.get("POSTGRES_PASSWORD", ""),
|
||||
"HOST": os.environ.get("POSTGRES_HOST", "localhost"),
|
||||
"PORT": "",
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
# Password validation
|
||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#auth-password-validators
|
||||
|
||||
AUTH_PASSWORD_VALIDATORS = [
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
||||
},
|
||||
{
|
||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
REST_FRAMEWORK = {
|
||||
'DEFAULT_FILTER_BACKENDS': [
|
||||
'django_filters.rest_framework.DjangoFilterBackend',
|
||||
],
|
||||
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||
],
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.IsAuthenticated',
|
||||
],
|
||||
}
|
||||
|
||||
SIMPLE_JWT = {
|
||||
'ACCESS_TOKEN_LIFETIME': datetime.timedelta(minutes=5),
|
||||
'REFRESH_TOKEN_LIFETIME': datetime.timedelta(days=5),
|
||||
'ROTATE_REFRESH_TOKENS': False,
|
||||
'BLACKLIST_AFTER_ROTATION': True,
|
||||
'UPDATE_LAST_LOGIN': False,
|
||||
}
|
||||
|
||||
PASSWORD_RESET_TIMEOUT = 600 # seconds = 10 minutes
|
||||
|
||||
# Internationalization
|
||||
# https://docs.djangoproject.com/en/4.2/topics/i18n/
|
||||
|
||||
LANGUAGE_CODE = 'en-us'
|
||||
|
||||
TIME_ZONE = CELERY_TIMEZONE = os.environ.get("TIME_ZONE", "Europe/London")
|
||||
TIME_ZONE_OBJ = pytz.timezone(TIME_ZONE)
|
||||
|
||||
USE_I18N = True
|
||||
|
||||
USE_TZ = True
|
||||
|
||||
# Celery
|
||||
CELERY_BROKER_URL = 'redis://localhost:6379/0'
|
||||
CELERY_RESULT_BACKEND = 'redis://localhost:6379/0'
|
||||
|
||||
CACHES = {
|
||||
'default': ({
|
||||
'BACKEND': 'django.core.cache.backends.locmem.LocMemCache',
|
||||
} if DEBUG else {
|
||||
"BACKEND": "django_redis.cache.RedisCache",
|
||||
"LOCATION": "redis://0.0.0.0:6379/1",
|
||||
"OPTIONS": {
|
||||
"CLIENT_CLASS": "django_redis.client.DefaultClient",
|
||||
"CONNECTION_POOL_KWARGS": {
|
||||
"max_connections": 100,
|
||||
"retry_on_timeout": True
|
||||
},
|
||||
"SOCKET_CONNECT_TIMEOUT": 5,
|
||||
"SOCKET_TIMEOUT": 5,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
# Static files (CSS, JavaScript, Images)
|
||||
# https://docs.djangoproject.com/en/4.2/howto/static-files/
|
||||
|
||||
STATIC_URL = 'apistatic/'
|
||||
STATIC_ROOT = BASE_DIR / 'static'
|
||||
|
||||
# Default primary key field type
|
||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
|
||||
|
||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
||||
|
||||
|
||||
# Strava API
|
||||
STRAVA_CLIENT_ID = int(os.environ.get("STRAVA_CLIENT_ID", 1234321))
|
||||
STRAVA_CLIENT_SECRET = os.environ.get("STRAVA_CLIENT_SECRET", "ReplaceWithClientSecret")
|
||||
STRAVA_LIMIT_15MIN = int(os.environ.get("STRAVA_LIMIT_15MIN", 100))
|
||||
STRAVA_LIMIT_DAY = int(os.environ.get("STRAVA_LIMIT_DAY", 1000))
|
||||
|
||||
|
||||
# Sentry
|
||||
if (sentry_sdk_url := os.environ.get("REACT_APP_SENTRY_DSN", None)) is not None:
|
||||
sentry_sdk.init(
|
||||
dsn=sentry_sdk_url,
|
||||
environment="backend",
|
||||
send_default_pii=False,
|
||||
enable_tracing=True,
|
||||
traces_sample_rate=0.25,
|
||||
profiles_sample_rate=1.0,
|
||||
integrations=[
|
||||
DjangoIntegration(),
|
||||
CeleryIntegration(monitor_beat_tasks=True),
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
# Emails
|
||||
EMAIL_BACKEND = "django.core.mail.backends.smtp.EmailBackend"
|
||||
EMAIL_HOST = os.environ.get("EMAIL_HOST", None)
|
||||
EMAIL_PORT = int(os.environ.get("EMAIL_PORT", 25))
|
||||
EMAIL_HOST_USER = os.environ.get("EMAIL_HOST_USER", None)
|
||||
EMAIL_HOST_PASSWORD = os.environ.get("EMAIL_HOST_PASSWORD", None)
|
||||
EMAIL_USE_TLS = None if (use_ssl := os.environ.get("EMAIL_USE_TLS", None)) is None else bool(use_ssl)
|
||||
EMAIL_USE_SSL = None if (use_ssl := os.environ.get("EMAIL_USE_SSL", None)) is None else bool(use_ssl)
|
||||
EMAIL_FROM = DEFAULT_FROM_EMAIL = os.environ.get("EMAIL_FROM", None)
|
||||
EMAIL_REPLY_TO = None if (reply_email := os.environ.get("EMAIL_REPLY_TO", None)) is None else reply_email.split(",")
|
||||
|
||||
|
||||
# OpenAI for AI quotes
|
||||
OPENAI_API_KEY = os.environ.get("OPENAI_API_KEY", None)
|
||||
60
src-backend/workout_challenge/urls.py
Normal file
60
src-backend/workout_challenge/urls.py
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
"""
|
||||
URL configuration for workout_challenge project.
|
||||
|
||||
The `urlpatterns` list routes URLs to views. For more information please see:
|
||||
https://docs.djangoproject.com/en/4.2/topics/http/urls/
|
||||
Examples:
|
||||
Function views
|
||||
1. Add an import: from my_app import views
|
||||
2. Add a URL to urlpatterns: path('', views.home, name='home')
|
||||
Class-based views
|
||||
1. Add an import: from other_app.views import Home
|
||||
2. Add a URL to urlpatterns: path('', Home.as_view(), name='home')
|
||||
Including another URLconf
|
||||
1. Import the include() function: from django.urls import include, path
|
||||
2. Add a URL to urlpatterns: path('blog/', include('blog.urls'))
|
||||
"""
|
||||
from django.contrib import admin
|
||||
from django.urls import path, include
|
||||
from rest_framework_simplejwt.views import (
|
||||
TokenObtainPairView,
|
||||
TokenRefreshView,
|
||||
)
|
||||
from rest_framework.routers import DefaultRouter
|
||||
from competition.views import CompetitionViewSet, TeamViewSet, ActivityGoalViewSet, PointsViewSet, CompetitionStatsQueryView, FeedQueryView, JoinCompetitionView, JoinTeamView, CeleryQueryView
|
||||
from workouts.views import WorkoutViewSet
|
||||
from custom_user.views import CustomUserViewSet, LinkStravaView, UnlinkStravaView, SyncStravaView, PasswordResetView, PasswordResetConfirmView
|
||||
|
||||
router = DefaultRouter()
|
||||
router.register(r'competition', CompetitionViewSet, basename='competition')
|
||||
router.register(r'team', TeamViewSet, basename='teams')
|
||||
router.register(r'goal', ActivityGoalViewSet, basename='goal')
|
||||
router.register(r'workout', WorkoutViewSet, basename='workout')
|
||||
router.register(r'point', PointsViewSet, basename='points')
|
||||
router.register(r'user', CustomUserViewSet, basename='cutomuser')
|
||||
|
||||
urlpatterns = [
|
||||
path('api/', include([
|
||||
path('', include(router.urls)),
|
||||
path('stats/<int:competition>/', CompetitionStatsQueryView.as_view(), name='competition-stats'),
|
||||
path('feed/<int:competition>/', FeedQueryView.as_view(), name='competition-feed'),
|
||||
path('join/competition/<str:join_code>/', JoinCompetitionView.as_view(), name='join-competition'),
|
||||
path('join/team/', JoinTeamView.as_view(), name='join-team'),
|
||||
path('strava/link/<str:code>/', LinkStravaView.as_view(), name='strava-link'),
|
||||
path('strava/unlink/', UnlinkStravaView.as_view(), name='strava-unlink'),
|
||||
path('strava/sync/', SyncStravaView.as_view(), name='strava-sync'),
|
||||
path('celery/tasks/', CeleryQueryView.as_view(), name='celery-task-list'),
|
||||
path('celery/tasks/<str:task_id>/', CeleryQueryView.as_view(), name='celery-task-status'),
|
||||
path('celery/', CeleryQueryView.as_view(), name='celery-task-run'),
|
||||
path('token/', TokenObtainPairView.as_view(), name='token-initial'),
|
||||
path('token/refresh/', TokenRefreshView.as_view(), name='token-refresh'),
|
||||
path('password-reset/request/', PasswordResetView.as_view(), name='password-reset'),
|
||||
path('password-reset/confirm/', PasswordResetConfirmView.as_view(), name='password-reset-confirm'),
|
||||
])),
|
||||
path('admin/', admin.site.urls),
|
||||
]
|
||||
|
||||
|
||||
admin.site.site_header = 'Backend Admin Panel'
|
||||
admin.site.site_title = 'Workout Challenge Backend'
|
||||
admin.site.index_title = 'Welcome to the Workout Challenge Backend'
|
||||
16
src-backend/workout_challenge/wsgi.py
Normal file
16
src-backend/workout_challenge/wsgi.py
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
"""
|
||||
WSGI config for workout_challenge project.
|
||||
|
||||
It exposes the WSGI callable as a module-level variable named ``application``.
|
||||
|
||||
For more information on this file, see
|
||||
https://docs.djangoproject.com/en/4.2/howto/deployment/wsgi/
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
from django.core.wsgi import get_wsgi_application
|
||||
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'workout_challenge.settings')
|
||||
|
||||
application = get_wsgi_application()
|
||||
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