diff --git a/rowers/models.py b/rowers/models.py index c9bfcbd4..5823717e 100644 --- a/rowers/models.py +++ b/rowers/models.py @@ -2391,6 +2391,111 @@ regularsessiontypechoices = ( ) # model for Planned Session (Workout, Challenge, Test) +class PlannedSessionStep(models.Model): + intensitytypes = ( + ("Active", "Active"), + ("Rest", "Rest"), + ("Warmup", "Warmup"), + ("Cooldown", "Cooldown") + ) + + durationtypes = ( + ("Distance", "Distance"), + ("Time", "Time"), + ('RepeatUntilStepsCmplt','Repeat previous blocks n times') + ) + + targettypes = ( + ("Speed", "Speed"), + ("HeartRate", "HeartRate"), + ("Cadence", "Cadence"), + ("Power", "Power") + ) + + manager = models.ForeignKey(User, null=True, on_delete=models.CASCADE) + name = models.TextField(default='',max_length=200, blank=True, null=True) + type = models.TextField(default='',max_length=200, blank=True, null=True) + durationvalue = models.FloatField(default=0, verbose_name="Duration Value") + durationtype = models.TextField(default='Time',max_length=200, + choices=durationtypes, + verbose_name='Duration Type') + targetvalue = models.IntegerField(default=0, verbose_name="Target Value") + targettype = models.TextField(default='',max_length=200, blank=True, null=True, + choices=targettypes, verbose_name="Target Type") + targetvaluelow = models.IntegerField(default=0, + verbose_name="Target Value Low") + targetvaluehigh = models.IntegerField(default=0, + verbose_name="Target Value High") + intensity = models.TextField(default='',max_length=200, blank=True, null=True, + choices=intensitytypes, + verbose_name = "Intensity") + description = models.TextField(default='',max_length=200, blank=True, null=True) + color = models.TextField(default='#ddd',max_length=200) + + def save(self, *args, **kwargs): + if self.intensity == "Warmup": + self.color = "#ffcccb" + elif self.intensity == "Cooldown": + self.color = '#90ee90' + elif self.intensity == "Rest": + self.color = 'add8e6' + if self.durationtype == 'RepeatUntilStepsCmplt': + self.color = 'ffffa7' + + self.durationvalue = int(self.durationvalue) + + super(PlannedSessionStep, self).save(*args, **kwargs) + + def asdict(self): + d = { + 'wkt_step_name': self.name, + 'durationType': self.durationtype, + 'durationValue': self.durationvalue, + 'targetType': self.targettype, + 'targetValue': self.targetvalue, + 'targetValueLow': self.targetvaluelow, + 'targetValueHigh': self.targetvaluehigh, + 'description': self.description, + 'stepId': self.pk, + 'intensity': self.intensity, + } + + return d + +class StepEditorForm(ModelForm): + class Meta: + model = PlannedSessionStep + fields = [ + 'name', + #'type', + 'durationtype', + 'durationvalue', + 'targettype', + 'targetvalue', + 'targetvaluelow', + 'targetvaluehigh', + 'intensity', + 'description', + ] + + widgets = { + 'name': forms.Textarea(attrs={'rows':1, 'cols':50}), + } + + def __init__(self, *args, **kwargs): + super(StepEditorForm, self).__init__(*args, **kwargs) + if self.instance.durationtype == 'Time': + self.initial['durationvalue'] = self.instance.durationvalue / 60000 + elif self.instance.durationtype == 'Distance': + self.initial['durationvalue'] = self.instance.durationvalue / 100 + + def save(self, *args, **kwargs): + # conversions + if self.instance.durationtype == 'Time': + self.instance.durationvalue *= 60000 + elif self.instance.durationtype == 'Distance': + self.instance.durationvalue *= 100 + return super(StepEditorForm, self).save(*args, **kwargs) class PlannedSession(models.Model): diff --git a/rowers/templates/plannedsessiontemplatecreate.html b/rowers/templates/plannedsessiontemplatecreate.html index fbda21b5..c1d89a73 100644 --- a/rowers/templates/plannedsessiontemplatecreate.html +++ b/rowers/templates/plannedsessiontemplatecreate.html @@ -34,6 +34,8 @@ + + {% endblock %} {% block sidebar %} diff --git a/rowers/templates/plannedsessiontemplateedit.html b/rowers/templates/plannedsessiontemplateedit.html index d3c15a01..ea18075b 100644 --- a/rowers/templates/plannedsessiontemplateedit.html +++ b/rowers/templates/plannedsessiontemplateedit.html @@ -35,6 +35,17 @@ +
  • +

    Steps

    + {% if steps %} +

    {{ steps|safe }}

    + {% else %} + No Steps defined + {% endif %} +

    + Edit Steps (experimental) +

    +
  • {% endblock %} diff --git a/rowers/templates/stepedit.html b/rowers/templates/stepedit.html new file mode 100644 index 00000000..c53575dd --- /dev/null +++ b/rowers/templates/stepedit.html @@ -0,0 +1,97 @@ +{% extends "newbase.html" %} +{% load static %} +{% load rowerfilters %} + +{% block title %}Rowsandall Training Plans{% endblock %} + + +{% block main %} +

    Edit {{ step.name }}

    + +{% endblock %} + + +{% block sidebar %} +{% include 'menu_plan.html' %} +{% endblock %} diff --git a/rowers/templates/stepeditor.html b/rowers/templates/stepeditor.html new file mode 100644 index 00000000..0d82a71b --- /dev/null +++ b/rowers/templates/stepeditor.html @@ -0,0 +1,343 @@ +{% extends "newbase.html" %} +{% load static %} +{% load rowerfilters %} + +{% block title %}Rowsandall Training Plans{% endblock %} + + +{% block main %} +

    Plan Training Steps

    +

    + WARNING: This is experimental functionality which may not behave as you + expect. Does not work on smartphones. +

    +

    + Drag from Library to Training to add a step to the end. + Drag on top of a training step to insert after it. + Drag out of Training to remove a step. +

    +
    +
    +

    Training Steps for {{ ps.name }}

    +

    +

    + + +
    +

    +{% for step in currentsteps %} +
    + + ({{ forloop.counter|add:-1 }}) + + + {{ step.name }} + {% if step.durationtype == "RepeatUntilStepsCmplt" %} + - repeat {{ step.targetvalue }}x from block {{ step.durationvalue|floatformat }} + {% endif %} + + + + + + + + + + + +
    +{% endfor %} +
    +
    +
    +

    +
    +
    +
    +

    Step Library

    + {% for step in steps %} +
    + + + + {{ step.name }} + {% if step.durationtype == "RepeatUntilStepsCmplt" %} + - repeat {{ step.targetvalue }}x from block {{ step.durationvalue|floatformat }} + {% endif %} + + + + + + + + + + + +
    + {% endfor %} +
    + + +{% endblock %} + +{% block scripts %} + + +{% endblock %} + +{% block sidebar %} +{% include 'menu_plan.html' %} +{% endblock %} diff --git a/rowers/templates/trainingplan_create.html b/rowers/templates/trainingplan_create.html index 7f683aa4..2205f102 100644 --- a/rowers/templates/trainingplan_create.html +++ b/rowers/templates/trainingplan_create.html @@ -123,15 +123,20 @@ {% if plan.status %} active {% else %} inactive {% endif %} {% if request.user.rower == plan.manager %} - Edit + {% endif %} - Plan + {% if request.user.rower == plan.manager %} - Delete + {% endif %} + + + + + {% endfor %} diff --git a/rowers/urls.py b/rowers/urls.py index 4251c30e..49cfa865 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -875,6 +875,18 @@ urlpatterns = [ views.rower_create_trainingplan, name='rower_create_trainingplan'), re_path(r'^plans/$', views.rower_select_instantplan, name='rower_select_instantplan'), + re_path(r'^plans/step/(?P\d+)/edit/$', + views.stepedit, name='stepedit'), + re_path(r'^plans/step/(?P\d+)/edit/(?P\d+)/$', + views.stepedit, name='stepedit'), + re_path(r'^plans/step/(?P\d+)/delete/$', + views.stepdelete, name='stepdelete'), + re_path(r'^plans/stepeditor/$', + views.stepeditor, name='stepeditor'), + re_path(r'^plans/stepeditor/(?P\d+)/$', + views.stepeditor, name='stepeditor'), + re_path(r'^plans/stepadder/(?P\d+)/$', + views.stepadder, name='stepadder'), re_path(r'^plans/(?P[0-9a-fA-F]{8}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{4}\-[0-9a-fA-F]{12})/$', views.rower_view_instantplan, name='rower_view_instantplan'), re_path(r'^buyplan/(?P\d+)/$', views.buy_trainingplan_view, diff --git a/rowers/views/planviews.py b/rowers/views/planviews.py index ade55c80..0a6221d4 100644 --- a/rowers/views/planviews.py +++ b/rowers/views/planviews.py @@ -6,6 +6,8 @@ from rowers.views.statements import * import rowers.garmin_stuff as gs from rowers import credits +from json.decoder import JSONDecodeError +from rowers.utils import step_to_string @login_required @@ -1403,9 +1405,24 @@ def save_plan_yaml(request, userid=0): for ps in sps: if ps.preferreddate == dd: sessionsport = mytypes.fitmapping[ps.sessionsport].capitalize() - steps = ps.steps - steps['filename'] = "" - workouts.append(steps) + if ps.steps: + steps = ps.steps + steps['filename'] = "" + workouts.append(steps) + else: + if ps.sessionmode == 'distance': + ps.interval_string = '{d}m'.format(d=ps.sessionvalue) + elif ps.sessionmode == 'time': + ps.interval_string = '{d}min'.format(d=ps.sessionvalue) + ps.fitfile = '' + ps.steps = None + ps.save() + ps_reload = PlannedSession.objects.get(id=ps.id) + steps = ps_reload.steps + steps['filename'] = "" + steps['workoutName'] = ps.name + print(steps) + workouts.append(steps) trainingdays.append({'order': i+1, 'workouts': workouts}) @@ -2003,6 +2020,12 @@ def plannedsession_templateedit_view(request, id=0): sessiontemplates = sessiontemplates | sessiontemplates2 + steps = '' + if ps.steps: # pragma: no cover + d = ps.steps + + steps = ps_dict_get_description_html(d, short=False) + return render(request, 'plannedsessiontemplateedit.html', { 'teams': get_my_teams(request.user), @@ -2013,6 +2036,7 @@ def plannedsession_templateedit_view(request, id=0): 'thesession': ps, 'sessiontemplates': sessiontemplates, 'rower': r, + 'steps': steps, }) @@ -2959,6 +2983,286 @@ def rower_create_trainingplan(request, id=0): 'old_targets': old_targets, }) +@user_passes_test(can_plan, login_url="/rowers/paidplans", + message="This functionality requires a Coach or Self-Coach plan", + redirect_field_name=None) +def stepadder(request, id=0): + is_ajax = request.META.get('HTTP_X_REQUESTED_WITH') == 'XMLHttpRequest' + if not is_ajax: + return JSONResponse( + status=403, data={ + 'status': 'false', + 'message': 'this view cannot be accessed directly' + } + ) + ps = get_object_or_404(PlannedSession, pk=id) + + is_save = request.GET.get('save',0) + + if request.method != 'POST': + message = {'status': 'false', + 'message': 'this view cannot be accessed through GET'} + return JSONResponse(status=403, data=message) + + try: + json_data = json.loads(request.body) + post_data = json_data + except (KeyError, JSONDecodeError): + q = request.POST + post_data = {k: q.getlist(k) if len( + q.getlist(k)) > 1 else v for k, v in q.items()} + + # only allow local host + hostt = request.get_host().split(':') + if hostt[0] not in ['localhost', '127.0.0.1', 'dev.rowsandall.com', 'rowsandall.com']: + message = {'status': 'false', + 'message': 'permission denied for host '+hostt[0]} + return JSONResponse(status=403, data=message) + + if ps.steps: + filename = ps.steps.get('filename','') + sport = ps.steps.get('sport','rowing') + else: + filename = '' + sport = 'rowing' + + steps = { + "filename": filename, + "sport": sport, + "steps": [] + } + + for nr,id in enumerate(post_data): + try: + step = PlannedSessionStep.objects.get(id=int(id)) + # add JSON + d = step.asdict() + d['stepId'] = nr + steps['steps'].append(d) + except PlannedSessionStep.DoesNotExist: + pass + + if is_save: + # save the darn thing + ps.steps = steps + + ps.interval_string = '' + ps.fitfile = None + ps.save() + + return JSONResponse(status=200,data=post_data) + +@user_passes_test(can_plan, login_url="/rowers/paidplans", + message="This functionality requires a Coach or Self-Coach plan", + redirect_field_name=None) +def stepdelete(request, id=0): + step = get_object_or_404(PlannedSessionStep, pk=id) + + step.delete() + + backid = request.GET.get('id') + + url = reverse(stepeditor,kwargs={'id':backid}) + + return HttpResponseRedirect(url) + +@user_passes_test(can_plan, login_url="/rowers/paidplans", + message="This functionality requires a Coach or Self-Coach plan", + redirect_field_name=None) +def stepedit(request, id=0, psid=0): + step = get_object_or_404(PlannedSessionStep, pk=id) + try: + ps = PlannedSession.objects.get(id=psid) + except PlannedSession.DoesNotExist: + ps = None + + form = StepEditorForm(instance=step) + + if request.method == 'POST': + form = StepEditorForm(request.POST) + if form.is_valid(): + if ps: + dd = step.asdict() + dd.pop('stepId') + for id,ss in enumerate(ps.steps['steps']): + ee = ss.copy() + ee.pop('stepId') + + if (dd == ee): + ss['durationType'] = form.cleaned_data['durationtype'] + ss['durationValue'] = form.cleaned_data['durationvalue'] + ss['targetType'] = form.cleaned_data['targettype'] + ss['targetValue'] = form.cleaned_data['targetvalue'] + ss['targetValueLow']= form.cleaned_data['targetvaluelow'] + ss['targetValueHigh'] = form.cleaned_data['targetvaluehigh'] + ss['intensity'] = form.cleaned_data['intensity'] + ss['wkt_step_name'] = form.cleaned_data['name'] + ss['description'] = form.cleaned_data['description'] + + if form.cleaned_data['durationtype'] == 'Time': + ss['durationValue'] = form.cleaned_data['durationvalue']*60000 + elif form.cleaned_data['durationtype'] == 'Distance': + ss[durationValue] = form.cleaned_data['durationvalue']*100 + + ss['durationValue'] = int(ss['durationValue']) + ps.fitfile = None + ps.interval_string = "" + + ps.save() + + step.durationtype = form.cleaned_data['durationtype'] + step.durationvalue = form.cleaned_data['durationvalue'] + step.targettype = form.cleaned_data['targettype'] + step.targetvalue = form.cleaned_data['targetvalue'] + step.targetvaluelow = form.cleaned_data['targetvaluelow'] + step.targetvaluehigh = form.cleaned_data['targetvaluehigh'] + step.intensity = form.cleaned_data['intensity'] + step.name = form.cleaned_data['name'] + step.description = form.cleaned_data['description'] + + if step.durationtype == 'Time': + step.durationvalue *= 60000 + elif step.durationtype == 'Distance': + step.durationvalue *= 100 + + step.save() + + + if step.durationtype == 'Time': + form.fields['durationvalue'].initial = step.durationvalue / 60000 + elif step.durationtype == 'Distance': + form.fields['durationvalue'].initial = step.durationvalue / 100 + + + stepdescription = step_to_string(step.asdict(), short=False)[0] + + if request.method == 'POST': + if 'stepsave_and_return' in request.POST: + url = reverse('stepeditor',kwargs = {'id': ps.id}) + return HttpResponseRedirect(url) + + breadcrumbs = [ + { + 'url': reverse('template_library_view'), + 'name': 'Session Templates' + }, + { + 'url': reverse('plannedsession_templateedit_view', kwargs={'id': ps.id}), + 'name': ps.name + }, + { + 'url': reverse('stepeditor', kwargs={'id':ps.id}), + 'name': 'Edit Steps' + }, + { + 'url': reverse('stepedit', kwargs={'psid': ps.id, 'id': step.id}), + 'name': 'Edit Step' + } + ] + + return render(request,'stepedit.html', + { + 'step': step, + 'stepdescription': stepdescription, + 'form': form, + 'ps': ps, + 'breadcrumbs': breadcrumbs, + }) + + + +@user_passes_test(can_plan, login_url="/rowers/paidplans", + message="This functionality requires a Coach or Self-Coach plan", + redirect_field_name=None) +def stepeditor(request, id=0): + ps = get_object_or_404(PlannedSession, pk=id) + + currentsteps = [] + if ps.steps: + for step in ps.steps['steps']: + durationtype = step.get('durationType','') + durationvalue = step.get('durationValue',0) + targetvalue = step.get('targetValue',0) + targettype = step.get('targetType','') + targetvaluelow = step.get('targetValueLow',0) + targetvaluehigh = step.get('targetValueHigh',0) + intensity = step.get('intensity','Active') + + archived_steps = PlannedSessionStep.objects.filter( + manager = request.user, + durationtype = durationtype, + durationvalue = durationvalue, + targetvalue = targetvalue, + targettype = targettype, + targetvaluelow = targetvaluelow, + targetvaluehigh = targetvaluehigh, + intensity = intensity, + ) + if not archived_steps.count() and durationvalue != 0: + s = PlannedSessionStep( + manager = request.user, + durationtype = durationtype, + durationvalue = durationvalue, + targetvalue = targetvalue, + targettype = targettype, + targetvaluelow = targetvaluelow, + targetvaluehigh = targetvaluehigh, + intensity = intensity, + name = step.get('wkt_step_name','Step') + ) + s.save() + else: + s = archived_steps[0] + currentsteps.append(s) + + + form = StepEditorForm() + + if request.method == 'POST': + form = StepEditorForm(request.POST) + if form.is_valid(): + step = form.save(commit=False) + step.manager = request.user + step.save() + + + steps = PlannedSessionStep.objects.filter( + manager=request.user, + durationtype__in=['Time','Distance','RepeatUntilStepsCmplt'] + ).order_by( + 'intensity','-durationvalue','durationtype', + ) + + stepdescriptions = {} + + for step in steps: + stepdescriptions[step.id] = step_to_string(step.asdict(), short=False)[0] + + breadcrumbs = [ + { + 'url': reverse('template_library_view'), + 'name': 'Session Templates' + }, + { + 'url': reverse('plannedsession_templateedit_view', kwargs={'id': ps.id}), + 'name': ps.name + }, + { + 'url': reverse('stepeditor', kwargs={'id': ps.id}), + 'name': 'Edit Steps' + } + ] + + + return render(request, 'stepeditor.html', + { + 'steps':steps, + 'currentsteps': currentsteps, + 'stepdescriptions': stepdescriptions, + 'form':form, + 'ps':ps, + 'breadcrumbs': breadcrumbs, + }) @user_passes_test(can_plan, login_url="/rowers/paidplans", message="This functionality requires a Coach or Self-Coach plan", diff --git a/rowers/views/statements.py b/rowers/views/statements.py index 8232bfc6..257391fe 100644 --- a/rowers/views/statements.py +++ b/rowers/views/statements.py @@ -151,7 +151,8 @@ from rowers.models import ( PlannedSessionComment, CoachRequest, CoachOffer, VideoAnalysis, ShareKey, StandardCollection, CourseStandard, - VirtualRaceFollower, TombStone, InstantPlan + VirtualRaceFollower, TombStone, InstantPlan, + PlannedSessionStep, ) from rowers.models import ( RowerPowerForm, RowerHRZonesForm, RowerForm, RowerCPForm, GraphImage, AdvancedWorkoutForm, @@ -166,7 +167,8 @@ from rowers.models import ( IndoorVirtualRaceResultForm, IndoorVirtualRaceResult, IndoorVirtualRaceForm, PlannedSessionCommentForm, Alert, Condition, StaticChartRowerForm, - FollowerForm, VirtualRaceAthleteForm, InstantPlanForm, DataRowerForm + FollowerForm, VirtualRaceAthleteForm, InstantPlanForm, DataRowerForm, + StepEditorForm, ) from rowers.models import ( FavoriteForm, BaseFavoriteFormSet, SiteAnnouncement, BasePlannedSessionFormSet, diff --git a/static/css/rowsandall2.css b/static/css/rowsandall2.css index c4d36853..926f970a 100644 --- a/static/css/rowsandall2.css +++ b/static/css/rowsandall2.css @@ -322,14 +322,70 @@ th.rotate > div > span { margin: 2px; } + +.stepcontainer { + display: grid; + grid-template-columns: 4fr 1fr 4fr; + gap: 10px; +} + +.trainingstep { + border: 3px solid #666; + background-color: #ddd; + border-radius: .5em; + padding: 10px; + cursor: move; +} + +.trainingstep.over { + border: 3px dotted #666; +} + +.trainingstep.Warmup { + background-color: #ffcccb; +} + +.trainingstep.Cooldown { + background-color: #90ee90; +} + +.trainingstep.Rest { + background-color: #add8e6; +} + +.RepeatUntilStepsCmplt { + background-color: #ffffa7; +} + +.drop-zone { + position: relative; + overflow: hidden; + background-color: #D7D7D7; + /* color: white; */ + padding: 10px; + padding-bottom: 40px; +} + +.allcentered { + /* Center vertically and horizontally */ + display: flex; + justify-content: center; + align-items: center; +} + +.allcenteredchild { + margin-top: 50%; +} + .divlines { - display: block; - overflow-x: hidden; - border-width: 1px 0 0 0; - border-color: #333 #333 #333 #333; - border-style: solid; - padding: 0px; - margin: 0px; + width: 50px; + height: 50px; + background-color: red; + /* Center vertically and horizontally */ + position: absolute; + top: 50%; + left: 50%; + margin: -25px 0 0 -25px; /* Apply negative top and left margins to truly center the element */ } .workoutcontainer {