From 34b11f86c22a39309443198ee28a68c781c0c44d Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Wed, 11 Dec 2024 23:10:14 +0100 Subject: [PATCH 01/19] list of intervals --- rowers/integrations/intervals.py | 45 +++++++++++++++++++- rowers/templates/intervals_list_import.html | 47 +++++++++++++++++++++ rowers/urls.py | 2 + rowers/views/importviews.py | 45 +++++++++++++++++++- 4 files changed, 137 insertions(+), 2 deletions(-) create mode 100644 rowers/templates/intervals_list_import.html diff --git a/rowers/integrations/intervals.py b/rowers/integrations/intervals.py index 4e0ac680..b6498821 100644 --- a/rowers/integrations/intervals.py +++ b/rowers/integrations/intervals.py @@ -63,7 +63,7 @@ class IntervalsIntegration(SyncIntegration): 'base_url': 'https://intervals.icu/api/v1/', 'grant_type': 'refresh_token', 'headers': headers, - 'scope': 'ACTIVITY:WRITE, LIBRARY:READ', + 'scope': 'ACTIVITY:WRITE, LIBRARY:READ, CALENDAR:WRITE', } def get_token(self, code, *args, **kwargs): @@ -297,5 +297,48 @@ class IntervalsIntegration(SyncIntegration): def token_refresh(self, *args, **kwargs): return super(IntervalsIntegration, self).token_refresh(*args, **kwargs) + def get_plannedsessions_list(self, *args, **kwargs): + _ = self.open() + r = self.rower + + headers = { + 'Authorization': 'Bearer ' + r.intervals_token, + } + + # first get the folders - we need the folder id for the next call + url = self.oauth_data['base_url'] + 'athlete/0/folders' + response = requests.get(url, headers=headers) + if response.status_code != 200: + return [] + + data = response.json() + # get all elements in the list where start_date_local is not None + folders = [x for x in data if x['start_date_local']] + for plan in folders: + plan_start_date = arrow.get(plan['start_date_local']).datetime + for session in plan["children"]: + session["date"] = (plan_start_date+timedelta(days=session["day"])).date() + + return folders + + def get_plannedsession(self, id, *args, **kwargs): + _ = self.open() + r = self.rower + + url = self.oauth_data['base_url'] + 'athlete/0/workouts/' + str(id) + headers = { + 'Authorization': 'Bearer ' + r.intervals_token, + } + + response = requests.get(url, headers=headers) + + if response.status_code != 200: + dologging('intervals.icu.log', response.text) + return 0 + + data = response.json() + + return data + diff --git a/rowers/templates/intervals_list_import.html b/rowers/templates/intervals_list_import.html new file mode 100644 index 00000000..ea3b410a --- /dev/null +++ b/rowers/templates/intervals_list_import.html @@ -0,0 +1,47 @@ +{% extends "newbase.html" %} +{% load static %} +{% load rowerfilters %} + +{% block title %}Sessions on intervals.icu{% endblock %} + +{% block main %} +

Sessions on Intervals.icu

+{% if folders %} + +{% endif %} +{% endblock %} diff --git a/rowers/urls.py b/rowers/urls.py index 5a4484da..71bb2228 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -630,6 +630,8 @@ urlpatterns = [ views.workout_undo_smoothenpace_view, name='workout_undo_smoothenpace_view'), re_path(r'^session/rojaboimport/$', views.workout_rojaboimport_view, name='workout_rojaboimport_view'), + re_path(r'^session/intervalsimport/$', views.plannedsession_intervalsimport_view, + name='plannedsession_intervalsimport_view'), re_path(r'^workout/(?P\w+.*)import/$', views.workout_import_view, name='workout_import_view'), re_path(r'^workout/(?P\w+.*)import/(?P\d+)/$', diff --git a/rowers/views/importviews.py b/rowers/views/importviews.py index a02527b0..c145347a 100644 --- a/rowers/views/importviews.py +++ b/rowers/views/importviews.py @@ -681,8 +681,51 @@ def rower_process_testcallback(request): # pragma: no cover return HttpResponse(text) +# view to list planned sessions from intervals.icu +@login_required() +@user_passes_test(isplanmember, login_url="/rowers/paidplans/", + message="This functionality requires a Self-coach plan or higher", + redirect_field_name=None) +def plannedsession_intervalsimport_view(request, message="", userid=0): + r = getrequestrower(request, userid=userid) + if r.user != request.user: + messages.error( + request, 'You can only access your own workouts on Intervals.icu, not those of your athletes') + url = reverse('plannedsession_intervalsimport_view', + kwargs={'userid': request.user.id}) + return HttpResponseRedirect(url) + + integration = importsources['intervals'](request.user) + sessions_list = integration.get_plannedsessions_list() + + if request.method == 'POST': # pragma: no cover + try: + tdict = dict(request.POST.lists()) + print(tdict) + ids = tdict['session'] + sessionids = [int(id) for id in ids] + for sessionid in sessionids: + try: + _ = integration.get_plannedsession(sessionid) + except NoTokenError: + pass + messages.info( + request, + 'Your Intervals.icu planned sessions will be imported in the background.' + ' It may take a few minutes before they appear.') + url = reverse('plannedsessions_view') + return HttpResponseRedirect(url) + except KeyError: + pass + + return render(request, 'intervals_list_import.html', + { + 'folders': sessions_list, + 'rower': r, + 'active': 'nav-plans', + }) + -# The page where you select which Strava workout to import @login_required() @user_passes_test(isplanmember, login_url="/rowers/paidplans/", message="This functionality requires a Self-coach plan or higher", From 9a7ecfeebc817c2633128e60ee5b18d052cdd3d4 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Thu, 12 Dec 2024 18:35:01 +0100 Subject: [PATCH 02/19] adding async sessions creation, adding link to plan --- rowers/models.py | 3 ++ rowers/plannedsessions.py | 64 ++++++++++++++++++++++----------------- rowers/tasks.py | 48 ++++++++++++++++++++++++++++- rowers/views/planviews.py | 2 +- 4 files changed, 87 insertions(+), 30 deletions(-) diff --git a/rowers/models.py b/rowers/models.py index f4715081..9ea4b747 100644 --- a/rowers/models.py +++ b/rowers/models.py @@ -2797,6 +2797,7 @@ class PlannedSession(models.Model): manager = models.ForeignKey(User, on_delete=models.PROTECT) rojabo_id = models.BigIntegerField(default=0,blank=True) + intervals_icu_id = models.CharField(default=None, blank=True, null=True, max_length=50) course = models.ForeignKey(GeoCourse, blank=True, null=True, verbose_name='OTW Course', on_delete=models.SET_NULL) @@ -2875,6 +2876,8 @@ class PlannedSession(models.Model): garmin_schedule_id = models.BigIntegerField(default=0) tags = TaggableManager(blank=True) + + from_plan = models.ForeignKey(TrainingPlan, on_delete=models.SET_NULL, null=True, blank=True) def __str__(self): diff --git a/rowers/plannedsessions.py b/rowers/plannedsessions.py index d9e6add5..b326b0b2 100644 --- a/rowers/plannedsessions.py +++ b/rowers/plannedsessions.py @@ -9,7 +9,7 @@ from rowers.models import ( from rowers.tasks import ( handle_sendemail_raceregistration, handle_sendemail_racesubmission ) -from rowers.tasks import handle_check_race_course +from rowers.tasks import handle_check_race_course, create_sessions_from_json_async from iso8601 import ParseError import iso8601 import rowers.courses as courses @@ -1068,40 +1068,48 @@ def get_workouts_session(r, ps): return ws -def create_sessions_from_json(plansteps, rower, startdate, manager, planbyrscore=False): +def create_sessions_from_json(plansteps, rower, startdate, manager, planbyrscore=False, plan=None, + asynchronous=False, queue=queue): trainingdays = plansteps['trainingDays'] planstartdate = startdate - for day in trainingdays: - for workout in day['workouts']: - sessionsport = 'water' - try: - sessionsport = mytypes.fitmappinginv[workout['sport'].lower()] - except KeyError: - pass + if not asynchronous: + for day in trainingdays: + for workout in day['workouts']: + sessionsport = 'water' + try: + sessionsport = mytypes.fitmappinginv[workout['sport'].lower()] + except KeyError: + pass - preferreddate = planstartdate+timedelta(days=day['order']) + preferreddate = planstartdate+timedelta(days=day['order']) - sessionmode = 'time' - if planbyrscore: - sessionmode = 'rScore' + sessionmode = 'time' + if planbyrscore: + sessionmode = 'rScore' - ps = PlannedSession( - startdate=preferreddate - - timedelta(days=preferreddate.weekday()), - enddate=preferreddate + - timedelta(days=-preferreddate.weekday()-1, weeks=1), - preferreddate=preferreddate, - sessionsport=sessionsport, # change this - name=workout['workoutName'], - steps=workout, - manager=manager, - sessionmode=sessionmode, - comment=workout['description'] - ) + ps = PlannedSession( + startdate=preferreddate - + timedelta(days=preferreddate.weekday()), + enddate=preferreddate + + timedelta(days=-preferreddate.weekday()-1, weeks=1), + preferreddate=preferreddate, + sessionsport=sessionsport, # change this + name=workout['workoutName'], + steps=workout, + manager=manager, + sessionmode=sessionmode, + comment=workout['description'], + from_plan=plan, + ) - ps.save() + ps.save() - add_rower_session(rower, ps) + add_rower_session(rower, ps) + return + + # async version + _ = myqueue(queue, create_sessions_from_json_async, plansteps, rower, startdate, manager, planbyrscore, plan) + def update_plannedsession(ps, cd): diff --git a/rowers/tasks.py b/rowers/tasks.py index a43a225d..b8ab9014 100644 --- a/rowers/tasks.py +++ b/rowers/tasks.py @@ -15,7 +15,7 @@ application = get_wsgi_application() from rowers.models import ( Workout, GeoPolygon, GeoPoint, GeoCourse, VirtualRaceResult, CourseTestResult, Rower, - GraphImage + GraphImage, Team, PlannedSession ) from rowers.session_utils import is_session_complete import math @@ -373,6 +373,52 @@ def handle_assignworkouts(workouts, rowers, remove_workout, debug=False, **kwarg return 1 +@app.task +def create_sessions_from_json_async(plansteps, rower, startdate, manager, planbyrscore, plan, debug=False, **kwargs): + trainingdays = plansteps['trainingDays'] + planstartdate = startdate + for day in trainingdays: + for workout in day['workouts']: + sessionsport = 'water' + try: + sessionsport = mytypes.fitmappinginv[workout['sport'].lower()] + except KeyError: + pass + + preferreddate = planstartdate+timedelta(days=day['order']) + + sessionmode = 'time' + if planbyrscore: + sessionmode = 'rScore' + + ps = PlannedSession( + startdate=preferreddate - + timedelta(days=preferreddate.weekday()), + enddate=preferreddate + + timedelta(days=-preferreddate.weekday()-1, weeks=1), + preferreddate=preferreddate, + sessionsport=sessionsport, # change this + name=workout['workoutName'], + steps=workout, + manager=manager, + sessionmode=sessionmode, + comment=workout['description'], + from_plan=plan, + ) + + ps.save() + + teams = Team.objects.filter(manager=ps.manager) + members = Rower.objects.filter(team__in=teams).distinct() + if rower in members and rower.rowerplan != 'freecoach': + ps.rower.add(rower) + ps.save() + elif ps.manager.rower == rower and rower.rowerplan != 'freecoach': + ps.rower.add(rower) + ps.save() + + return 1 + @app.task def handle_post_workout_api(uploadoptions, debug=False, **kwargs): # pragma: no cover session = requests.session() diff --git a/rowers/views/planviews.py b/rowers/views/planviews.py index eb0db639..eb1711a2 100644 --- a/rowers/views/planviews.py +++ b/rowers/views/planviews.py @@ -2729,7 +2729,7 @@ def rower_view_instantplan(request, id='', userid=0): p.save() p.rowers.add(r) - create_sessions_from_json(plansteps, r, startdate, r.user, planbyrscore=byrscore) + create_sessions_from_json(plansteps, r, startdate, r.user, planbyrscore=byrscore, plan=p, asynchronous=True) messages.info(request, 'Your Sessions have been added') From 6722833561aeab9130bfe3f381bc2017b1db4970 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Fri, 13 Dec 2024 12:02:59 +0100 Subject: [PATCH 03/19] can delete sessions with deletion of plan --- rowers/models.py | 15 +++++++++++++++ rowers/templates/trainingplan_delete.html | 10 +++++++++- rowers/views/planviews.py | 7 +++++++ 3 files changed, 31 insertions(+), 1 deletion(-) diff --git a/rowers/models.py b/rowers/models.py index 9ea4b747..15a66474 100644 --- a/rowers/models.py +++ b/rowers/models.py @@ -1978,6 +1978,21 @@ class TrainingPlan(models.Model): return stri + def delete(self, *args, **kwargs): + delete_sessions = kwargs.pop('delete_sessions', False) + delete_all_sessions = kwargs.pop('delete_all_sessions', False) + if delete_sessions: + sessions = PlannedSession.objects.filter(from_plan=self) + for s in sessions: + s.delete() + + if delete_all_sessions: + sessions = PlannedSession.objects.filter(startdate__gte=self.startdate,enddate__lte=self.enddate) + for s in sessions: + s.delete() + + super().delete(*args, **kwargs) + def save(self, *args, **kwargs): manager = self.manager if not can_add_plan(manager.user): # pragma: no cover diff --git a/rowers/templates/trainingplan_delete.html b/rowers/templates/trainingplan_delete.html index 045411e4..adaa75a7 100644 --- a/rowers/templates/trainingplan_delete.html +++ b/rowers/templates/trainingplan_delete.html @@ -13,7 +13,15 @@
{% csrf_token %}

Are you sure you want to delete {{ object }}?

- +

+ Delete all planned sessions linked to the plan +

+

+ Delete all planned sessions in the plan time frame +

+

+ +

diff --git a/rowers/views/planviews.py b/rowers/views/planviews.py index eb1711a2..e7731516 100644 --- a/rowers/views/planviews.py +++ b/rowers/views/planviews.py @@ -3335,6 +3335,13 @@ class TrainingPlanDelete(DeleteView): template_name = 'trainingplan_delete.html' success_url = reverse_lazy(rower_create_trainingplan) + def post(self, request, *args, **kwargs): + delete_sessions = request.POST.get('delete_sessions') + delete_all_sessions = request.POST.get('delete_all_sessions') + self.object = self.get_object() + self.object.delete(delete_sessions=delete_sessions, delete_all_sessions=delete_all_sessions) + return HttpResponseRedirect(self.get_success_url()) + def get_object(self, *args, **kwargs): obj = super(TrainingPlanDelete, self).get_object(*args, **kwargs) if not can_delete_plan(self.request.user, obj): # pragma: no cover From 8c5dd6bb914a642e1aebe8c24f200dde7cbcc32f Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Sun, 15 Dec 2024 17:04:17 +0100 Subject: [PATCH 04/19] not really importing the steps correctly --- rowers/integrations/intervals.py | 26 ++++++++----- rowers/templates/intervals_list_import.html | 10 ++--- rowers/views/importviews.py | 41 ++++++++++++--------- 3 files changed, 42 insertions(+), 35 deletions(-) diff --git a/rowers/integrations/intervals.py b/rowers/integrations/intervals.py index 32e191b0..eac69c03 100644 --- a/rowers/integrations/intervals.py +++ b/rowers/integrations/intervals.py @@ -322,30 +322,23 @@ class IntervalsIntegration(SyncIntegration): } # first get the folders - we need the folder id for the next call - url = self.oauth_data['base_url'] + 'athlete/0/folders' + url = self.oauth_data['base_url'] + 'athlete/0/events?category=WORKOUT' response = requests.get(url, headers=headers) if response.status_code != 200: return [] data = response.json() - # get all elements in the list where start_date_local is not None - folders = [x for x in data if x['start_date_local']] - for plan in folders: - plan_start_date = arrow.get(plan['start_date_local']).datetime - for session in plan["children"]: - session["date"] = (plan_start_date+timedelta(days=session["day"])).date() - return folders + return data def get_plannedsession(self, id, *args, **kwargs): _ = self.open() r = self.rower - url = self.oauth_data['base_url'] + 'athlete/0/workouts/' + str(id) + url = self.oauth_data['base_url'] + 'athlete/0/events/' + str(id) headers = { 'Authorization': 'Bearer ' + r.intervals_token, } - response = requests.get(url, headers=headers) if response.status_code != 200: @@ -354,6 +347,19 @@ class IntervalsIntegration(SyncIntegration): data = response.json() + # get file from athlete/0/events/{id}/downloadfit + url = self.oauth_data['base_url'] + 'athlete/0/events/' + str(id) + '/downloadfit' + response = requests.get(url, headers=headers) + if response.status_code != 200: + dologging('intervals.icu.log', response.text) + return 0 + + filename = 'media/planned_' + str(id) + '.fit' + with open(filename, 'wb') as f: + f.write(response.content) + + data['fitfile'] = filename + return data diff --git a/rowers/templates/intervals_list_import.html b/rowers/templates/intervals_list_import.html index ea3b410a..7ecad750 100644 --- a/rowers/templates/intervals_list_import.html +++ b/rowers/templates/intervals_list_import.html @@ -6,7 +6,7 @@ {% block main %}

Sessions on Intervals.icu

-{% if folders %} +{% if sessions %}