From 931bab29eab09c9f7a9cd7714b939cdc680859f8 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Sat, 7 Dec 2024 16:38:58 +0100 Subject: [PATCH 1/7] creating auth url, not implemented callback yet --- rowers/integrations/__init__.py | 2 + rowers/integrations/intervals.py | 92 ++++++++++++++++++++++++++++++++ rowers/models.py | 5 ++ rowsandall_app/settings.py | 15 ++++++ 4 files changed, 114 insertions(+) create mode 100644 rowers/integrations/intervals.py diff --git a/rowers/integrations/__init__.py b/rowers/integrations/__init__.py index dc76fdb1..409a1ce5 100644 --- a/rowers/integrations/__init__.py +++ b/rowers/integrations/__init__.py @@ -5,6 +5,7 @@ from .sporttracks import SportTracksIntegration from .rp3 import RP3Integration from .trainingpeaks import TPIntegration from .polar import PolarIntegration +from .intervals import IntervalsIntegration importsources = { 'c2': C2Integration, @@ -15,5 +16,6 @@ importsources = { 'tp':TPIntegration, 'rp3':RP3Integration, 'polar': PolarIntegration, + 'intervals': IntervalsIntegration, } diff --git a/rowers/integrations/intervals.py b/rowers/integrations/intervals.py new file mode 100644 index 00000000..e6d7e3cd --- /dev/null +++ b/rowers/integrations/intervals.py @@ -0,0 +1,92 @@ +from .integrations import SyncIntegration, NoTokenError, create_or_update_syncrecord, get_known_ids +from rowers.models import Rower, User, Workout, TombStone +from rowingdata import rowingdata + +from rowers import mytypes + +from rowers.rower_rules import is_workout_user, ispromember +from rowers.utils import myqueue, dologging, custom_exception_handler + +import urllib +import gzip +import requests +import arrow +import datetime +import os +from uuid import uuid4 +from django.utils import timezone +from datetime import timedelta + +from rowsandall_app.settings import ( + INTERVALS_CLIENT_ID, INTERVALS_REDIRECT_URI, INTERVALS_CLIENT_SECRET, SITE_URL +) + +import django_rq +queue = django_rq.get_queue('default', default_timeout=3600) +queuelow = django_rq.get_queue('low', default_timeout=3600) +queuehigh = django_rq.get_queue('high', default_timeout=3600) + +headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json' +} + +intervals_authorize_url = 'https://intervals.icu/oauth/authorize?' +intervals_token_url = 'https://intervals.icu/oauth/token' + +class IntervalsIntegration(SyncIntegration): + def __init__(self, *args, **kwargs): + super(IntervalsIntegration, self).__init__(*args, **kwargs) + self.oauth_data = { + 'client_id': INTERVALS_CLIENT_ID, + 'client_secret': INTERVALS_CLIENT_SECRET, + 'redirect_uri': INTERVALS_REDIRECT_URI, + 'authorization_uri': intervals_authorize_url, + 'content_type': 'application/json', + 'tokenname': 'intervals_token', + 'expirydatename': 'intervals_exp', + 'refreshtokenname': 'intervals_r', + 'bearer_auth': True, + 'base_uri': 'https://intervals.icu/api/v1/', + 'grant_type': 'refresh_token', + 'headers': headers, + 'scope': 'ACTIVITY:WRITE' + } + + def get_token(self, code, *args, **kwargs): + return super(IntervalsIntegration, self).get_token(code, *args, **kwargs) + + def get_name(self): + return 'Intervals' + + def get_shortname(self): + return 'intervals' + + def open(self, *args, **kwargs): + dologging('intervals.icu.log', "Getting token for user {id}".format(id=self.rower.id)) + token = super(IntervalsIntegration).open(*args, **kwargs) + return token + + def createworkoutdata(self, w, *args, **kwargs) -> str: + return NotImplemented + + def workout_export(self, workout, *args, **kwargs) -> str: + return NotImplemented + + def get_workouts(workout, *args, **kwargs) -> int: + return NotImplemented + + def get_workout(self, id, *args, **kwargs) -> int: + return NotImplemented + + def get_workout_list(self, *args, **kwargs) -> list: + return NotImplemented + + def make_authorization_url(self, *args, **kwargs): + return super(IntervalsIntegration, self).make_authorization_url(*args, **kwargs) + + def token_refresh(self, *args, **kwargs): + return super(IntervalsIntegration, self).token_refresh(*args, **kwargs) + + + diff --git a/rowers/models.py b/rowers/models.py index 857a2680..635bf3ca 100644 --- a/rowers/models.py +++ b/rowers/models.py @@ -1240,6 +1240,11 @@ class Rower(models.Model): strava_auto_import = models.BooleanField(default=False) strava_auto_delete = models.BooleanField(default=False) + intervals_token = models.CharField( + default='', max_length=200, blank=True, null=True) + intervals_exp = models.DateTimeField(blank=True, null=True) + intervals_r = models.CharField(default='', max_length=200, blank=True, null=True) + privacychoices = ( ('visible', 'Visible'), ('hidden', 'Hidden'), diff --git a/rowsandall_app/settings.py b/rowsandall_app/settings.py index 28f52fca..601a1652 100644 --- a/rowsandall_app/settings.py +++ b/rowsandall_app/settings.py @@ -296,6 +296,21 @@ C2_CLIENT_SECRET = CFG['c2_client_secret'] C2_REDIRECT_URI = CFG['c2_callback'] # C2_REDIRECT_URI = "http://localhost:8000/call_back" +# Intervals.icu +try: + INTERVALS_CLIENT_ID = CFG['intervals_client_id'] +except KeyError: + INTERVALS_CLIENT_ID = '0' + +try: + INTERVALS_CLIENT_SECRET = CFG['intervals_client_secret'] +except KeyError: + INTERVALS_CLIENT_SECRET = 'aa' +try: + INTERVALS_REDIRECT_URI = CFG['intervals_callback'] +except KeyError: + INTERVALS_REDIRECT_URI = 'http://localhost:8000/intervals_icu_callback' + # Strava STRAVA_CLIENT_ID = CFG['strava_client_id'] From 53e6fefbfe4b1bd4541c97209410993c9908aaf4 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Sun, 8 Dec 2024 17:44:45 +0100 Subject: [PATCH 2/7] import somewhat working (workout type not) --- rowers/integrations/integrations.py | 2 +- rowers/integrations/intervals.py | 102 +++++++++++++++++++-- rowers/models.py | 7 +- rowers/tasks.py | 69 ++++++++++++++ rowers/templates/menu_workouts.html | 1 + rowers/templates/rower_exportsettings.html | 2 + rowers/views/importviews.py | 32 +++++++ rowsandall_app/urls.py | 1 + static/img/intervals_icu.png | Bin 0 -> 3776 bytes static/img/ms-icon-120x120.png | Bin 0 -> 13739 bytes 10 files changed, 202 insertions(+), 14 deletions(-) create mode 100644 static/img/intervals_icu.png create mode 100644 static/img/ms-icon-120x120.png diff --git a/rowers/integrations/integrations.py b/rowers/integrations/integrations.py index 0cfaf0ad..2ff231a2 100644 --- a/rowers/integrations/integrations.py +++ b/rowers/integrations/integrations.py @@ -109,7 +109,7 @@ class SyncIntegration(metaclass=ABCMeta): if 'grant_type' in self.oauth_data: if self.oauth_data['grant_type']: post_data['grant_type'] = self.oauth_data['grant_type'] - if 'strava' in self.oauth_data['autorization_uri']: + if 'strava' in self.oauth_data['authorization_uri']: post_data['grant_type'] = "authorization_code" if 'json' in self.oauth_data['content_type']: diff --git a/rowers/integrations/intervals.py b/rowers/integrations/intervals.py index e6d7e3cd..185ef8d9 100644 --- a/rowers/integrations/intervals.py +++ b/rowers/integrations/intervals.py @@ -6,6 +6,7 @@ from rowers import mytypes from rowers.rower_rules import is_workout_user, ispromember from rowers.utils import myqueue, dologging, custom_exception_handler +from rowers.tasks import handle_intervals_getworkout import urllib import gzip @@ -26,13 +27,25 @@ queue = django_rq.get_queue('default', default_timeout=3600) queuelow = django_rq.get_queue('low', default_timeout=3600) queuehigh = django_rq.get_queue('high', default_timeout=3600) + +def seconds_to_duration(seconds): + hours = seconds // 3600 + minutes = (seconds % 3600) // 60 + remaining_seconds = seconds % 60 + + # Format as "H:MM:SS" or "MM:SS" if no hours + if hours > 0: + return f"{int(hours)}:{int(minutes):02}:{int(remaining_seconds):02}" + else: + return f"{int(minutes)}:{int(remaining_seconds):02}" + headers = { 'Content-Type': 'application/json', 'Accept': 'application/json' } intervals_authorize_url = 'https://intervals.icu/oauth/authorize?' -intervals_token_url = 'https://intervals.icu/oauth/token' +intervals_token_url = 'https://intervals.icu/api/oauth/token' class IntervalsIntegration(SyncIntegration): def __init__(self, *args, **kwargs): @@ -47,14 +60,33 @@ class IntervalsIntegration(SyncIntegration): 'expirydatename': 'intervals_exp', 'refreshtokenname': 'intervals_r', 'bearer_auth': True, - 'base_uri': 'https://intervals.icu/api/v1/', + 'base_url': 'https://intervals.icu/api/v1/', 'grant_type': 'refresh_token', 'headers': headers, - 'scope': 'ACTIVITY:WRITE' + 'scope': 'ACTIVITY:WRITE, LIBRARY:READ', } def get_token(self, code, *args, **kwargs): - return super(IntervalsIntegration, self).get_token(code, *args, **kwargs) + post_data = { + 'client_id': str(self.oauth_data['client_id']), + 'client_secret': self.oauth_data['client_secret'], + 'code': code, + } + + response = requests.post( + intervals_token_url, + data=post_data, + ) + + if response.status_code not in [200, 201]: + dologging('intervals.icu.log',response.text) + return [0,"Failed to get token. ",0] + + token_json = response.json() + access_token = token_json['access_token'] + athlete = token_json['athlete'] + + return [access_token, athlete, ''] def get_name(self): return 'Intervals' @@ -63,8 +95,8 @@ class IntervalsIntegration(SyncIntegration): return 'intervals' def open(self, *args, **kwargs): - dologging('intervals.icu.log', "Getting token for user {id}".format(id=self.rower.id)) - token = super(IntervalsIntegration).open(*args, **kwargs) + # dologging('intervals.icu.log', "Getting token for user {id}".format(id=self.rower.id)) + token = super(IntervalsIntegration, self).open(*args, **kwargs) return token def createworkoutdata(self, w, *args, **kwargs) -> str: @@ -73,13 +105,63 @@ class IntervalsIntegration(SyncIntegration): def workout_export(self, workout, *args, **kwargs) -> str: return NotImplemented - def get_workouts(workout, *args, **kwargs) -> int: - return NotImplemented + def get_workout_list(self, *args, **kwargs) -> int: + url = self.oauth_data['base_url'] + 'athlete/0/activities?' + startdate = timezone.now() - timedelta(days=365) + enddate = timezone.now() + timedelta(days=1) + url += 'oldest=' + startdate.strftime('%Y-%m-%d') + '&newest=' + enddate.strftime('%Y-%m-%d') + headers = { + 'accept': '*/*', + 'authorization': 'Bearer ' + self.open(), + } + + response = requests.get(url, headers=headers) + if response.status_code != 200: + dologging('intervals.icu.log', response.text) + return [] + + data = response.json() + known_interval_ids = get_known_ids(self.rower, 'intervalsid') + + workouts = [] + + for item in data: + i = item['id'] + r = item['type'] + d = item['distance'] + ttot = seconds_to_duration(item['moving_time']) + s = item['start_date'] + s2 = '' + c = item['name'] + if i in known_interval_ids: + nnn = '' + else: + nnn = 'NEW' + + keys = ['id','distance','duration','starttime', + 'rowtype','source','name','new'] + + values = [i, d, ttot, s, r, s2, c, nnn] + + ress = dict(zip(keys, values)) + workouts.append(ress) + + return workouts + def get_workout(self, id, *args, **kwargs) -> int: - return NotImplemented + _ = self.open() + r = self.rower - def get_workout_list(self, *args, **kwargs) -> list: + record = create_or_update_syncrecord(r, None, intervalsid=id) + + _ = myqueue(queuehigh, + handle_intervals_getworkout, + self.rower, + self.rower.intervals_token, + id) + + def get_workouts(workout, *args, **kwargs) -> list: return NotImplemented def make_authorization_url(self, *args, **kwargs): diff --git a/rowers/models.py b/rowers/models.py index 635bf3ca..2075d2cf 100644 --- a/rowers/models.py +++ b/rowers/models.py @@ -1242,8 +1242,7 @@ class Rower(models.Model): intervals_token = models.CharField( default='', max_length=200, blank=True, null=True) - intervals_exp = models.DateTimeField(blank=True, null=True) - intervals_r = models.CharField(default='', max_length=200, blank=True, null=True) + intervals_owner_id = models.CharField(default='', max_length=200,blank=True, null=True) privacychoices = ( ('visible', 'Visible'), @@ -3696,6 +3695,7 @@ class Workout(models.Model): uploadedtogarmin = models.BigIntegerField(default=0) uploadedtorp3 = models.BigIntegerField(default=0) uploadedtonk = models.BigIntegerField(default=0) + uploadedtointervals = models.BigIntegerField(default=0) forceunit = models.CharField(default='lbs', choices=( ('lbs', 'lbs'), @@ -3851,6 +3851,7 @@ class SyncRecord(models.Model): c2id = models.BigIntegerField(unique=True,null=True,default=None) tpid = models.BigIntegerField(unique=True,null=True,default=None) rp3id = models.BigIntegerField(unique=True,null=True,default=None) + intervalsid = models.BigIntegerField(unique=True, null=True, default=None) def save(self, *args, **kwargs): if self.workout: @@ -3866,7 +3867,7 @@ class SyncRecord(models.Model): str2 = '' - for field in ['stravaid', 'sporttracksid', 'nkid', 'c2id', 'tpid']: + for field in ['stravaid', 'sporttracksid', 'nkid', 'c2id', 'tpid', 'intervalsid']: value = getattr(self, field, None) if value is not None: str2 += '{w}: {v},'.format( diff --git a/rowers/tasks.py b/rowers/tasks.py index bad2cc3c..6fc8793a 100644 --- a/rowers/tasks.py +++ b/rowers/tasks.py @@ -24,6 +24,7 @@ from rowers.courseutils import ( InvalidTrajectoryError ) from rowers.emails import send_template_email +from rowers.mytypes import fitmappinginv from rowers.nkimportutils import ( get_nk_summary, get_nk_allstats, get_nk_intervalstats, getdict, strokeDataToDf, add_workout_from_data @@ -59,6 +60,8 @@ import rowingdata from rowingdata import make_cumvalues, make_cumvalues_array from uuid import uuid4 from rowingdata import rowingdata as rdata +from rowingdata import FITParser as FP +from rowingdata.otherparsers import FitSummaryData from datetime import timedelta @@ -3485,6 +3488,72 @@ def handle_nk_async_workout(alldata, userid, nktoken, nkid, delaysec, defaulttim return workoutid +@app.task +def handle_intervals_getworkout(rower, intervalstoken, workoutid, debug=False, **kwargs): + authorizationstring = str('Bearer '+intervalstoken) + headers = { + 'authorization': authorizationstring, + } + + url = "https://intervals.icu/api/v1/activity/{}".format(workoutid) + + response = requests.get(url, headers=headers) + if response.status_code != 200: + return 0 + + data = response.json() + try: + title = data['name'] + except KeyError: + title = 'Intervals workout' + + try: + workouttype = fitmappinginv[data['type']] + print(data['type']) + except KeyError: + workouttype = 'water' + + url = "https://intervals.icu/api/v1/activity/{workoutid}/fit-file".format(workoutid=workoutid) + + response = requests.get(url, headers=headers) + + if response.status_code != 200: + return 0 + + try: + fit_data = response.content + fit_filename = 'media/'+f'{uuid4().hex[:16]}.fit' + with open(fit_filename, 'wb') as fit_file: + fit_file.write(fit_data) + except Exception as e: + return 0 + + try: + row = FP(fit_filename) + rowdata = rowingdata.rowingdata(df=row.df) + rowsummary = FitSummaryData(fit_filename) + duration = totaltime_sec_to_string(rowdata.duration) + distance = rowdata.df[" Horizontal (meters)"].iloc[-1] + except Exception as e: + print(e) + return 0 + + uploadoptions = { + 'secret': UPLOAD_SERVICE_SECRET, + 'user': rower.user.id, + 'boattype': '1x', + 'workouttype': workouttype, + 'file': fit_filename, + 'title': title, + 'rpe': 0, + 'notes': '', + 'offline': False, + } + + url = UPLOAD_SERVICE_URL + handle_request_post(url, uploadoptions) + + return 1 @app.task def handle_c2_getworkout(userid, c2token, c2id, defaulttimezone, debug=False, **kwargs): diff --git a/rowers/templates/menu_workouts.html b/rowers/templates/menu_workouts.html index b85f728f..2beafb52 100644 --- a/rowers/templates/menu_workouts.html +++ b/rowers/templates/menu_workouts.html @@ -57,6 +57,7 @@
  • SportTracks
  • Polar
  • RP3
  • +
  • Intervals.icu
  • diff --git a/rowers/templates/rower_exportsettings.html b/rowers/templates/rower_exportsettings.html index 4990ce37..866a771a 100644 --- a/rowers/templates/rower_exportsettings.html +++ b/rowers/templates/rower_exportsettings.html @@ -123,6 +123,8 @@ alt="connect with RP3" width="130">

    connect with Rojabo

    +

    connect with intervals.icu

    {% endblock %} diff --git a/rowers/views/importviews.py b/rowers/views/importviews.py index f5270e17..486155b1 100644 --- a/rowers/views/importviews.py +++ b/rowers/views/importviews.py @@ -24,6 +24,7 @@ importauthorizeviews = { 'nk': 'rower_integration_authorize', 'rp3': 'rower_integration_authorize', 'garmin': 'rower_garmin_authorize', + 'intervals': 'rower_integration_authorize', } @@ -173,6 +174,37 @@ def rower_process_twittercallback(request): # pragma: no cover # Process Polar Callback +@login_required() +def rower_process_intervalscallback(request): + integration = importsources['intervals'](request.user) + r = getrower(request.user) + try: + code = request.GET['code'] + res = integration.get_token(code) + except MultiValueDictKeyError: + message = "The resource owner or authorization server denied the request" + messages.error(request, message) + + url = reverse('rower_exportsettings_view') + return HttpResponseRedirect(url) + + access_token = res[0] + athlete = res[1] + if access_token == 0: + message = res[1] + message += 'Connection to intervals.icu failed.' + messages.error(request, message) + url = reverse('rower_exportsettings_view') + return HttpResponseRedirect(url) + + r.intervals_token = access_token + r.intervals_owner_id = athlete['id'] + r.save() + + successmessage = "Tokens stored. Good to go. Please check your import/export settings" + messages.info(request, successmessage) + url = reverse('rower_exportsettings_view') + return HttpResponseRedirect(url) @login_required() def rower_process_polarcallback(request): diff --git a/rowsandall_app/urls.py b/rowsandall_app/urls.py index caf20824..67c26a3d 100644 --- a/rowsandall_app/urls.py +++ b/rowsandall_app/urls.py @@ -94,6 +94,7 @@ urlpatterns += [ re_path(r'^rp3\_callback', rowersviews.rower_process_rp3callback), re_path(r'^twitter\_callback', rowersviews.rower_process_twittercallback), re_path(r'^idoklad\_callback', rowersviews.process_idokladcallback), + re_path(r'^intervals\_icu\_callback', rowersviews.rower_process_intervalscallback), re_path(r'^i18n/', include('django.conf.urls.i18n')), re_path(r'^tz_detect/', include('tz_detect.urls')), re_path(r'^logo/', logoview), diff --git a/static/img/intervals_icu.png b/static/img/intervals_icu.png new file mode 100644 index 0000000000000000000000000000000000000000..a333c8b83e7bcfe8daba0691f4ec10014688b358 GIT binary patch literal 3776 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 Date: Mon, 9 Dec 2024 20:20:32 +0100 Subject: [PATCH 3/7] more changes --- rowers/dataroutines.py | 1 + rowers/models.py | 8 +++++-- rowers/mytypes.py | 38 ++++++++++++++++++++++++++++++++++ rowers/tasks.py | 8 +++---- rowers/uploads.py | 47 +++++++++++++++++++++++++++++++++++------- 5 files changed, 88 insertions(+), 14 deletions(-) diff --git a/rowers/dataroutines.py b/rowers/dataroutines.py index d5f1e168..61282da7 100644 --- a/rowers/dataroutines.py +++ b/rowers/dataroutines.py @@ -1367,6 +1367,7 @@ def get_workouttype_from_fit(filename, workouttype='water'): return 'water' try: workouttype = mytypes.fitmappinginv[fittype] + return workouttype except KeyError: # pragma: no cover return workouttype diff --git a/rowers/models.py b/rowers/models.py index 2075d2cf..610cd019 100644 --- a/rowers/models.py +++ b/rowers/models.py @@ -372,7 +372,7 @@ def update_records(url=c2url, verbose=True): # Create a DataFrame df = pd.DataFrame(rows, columns=headers) - except: # pragma: no cover + except: # pragma: no cover df = pd.DataFrame() if not df.empty: @@ -1172,6 +1172,8 @@ class Rower(models.Model): default='', max_length=200, blank=True, null=True) c2_auto_export = models.BooleanField(default=False) c2_auto_import = models.BooleanField(default=False) + intervals_auto_export = models.BooleanField(default=False) + intervals_auto_import = models.BooleanField(default=False) sporttrackstoken = models.CharField( default='', max_length=200, blank=True, null=True) sporttrackstokenexpirydate = models.DateTimeField(blank=True, null=True) @@ -3827,6 +3829,7 @@ class TombStone(models.Model): uploadedtosporttracks = models.BigIntegerField(default=0) uploadedtotp = models.BigIntegerField(default=0) uploadedtonk = models.BigIntegerField(default=0) + uploadedtointervals = models.BigIntegerField(default=0) @receiver(models.signals.pre_delete, sender=Workout) def create_tombstone_on_delete(sender, instance, **kwargs): @@ -3835,7 +3838,8 @@ def create_tombstone_on_delete(sender, instance, **kwargs): uploadedtoc2=instance.uploadedtoc2, uploadedtostrava=instance.uploadedtostrava, uploadedtotp=instance.uploadedtotp, - uploadedtonk=instance.uploadedtonk + uploadedtonk=instance.uploadedtonk, + uploadedtointervals=instance.uploadedtointervals, ) t.save() diff --git a/rowers/mytypes.py b/rowers/mytypes.py index afc90c8b..b0586308 100644 --- a/rowers/mytypes.py +++ b/rowers/mytypes.py @@ -180,6 +180,41 @@ fitcollection = ( fitmapping = {key: value for key, value in Reverse(fitcollection)} + + +intervalscollection = ( + ('water', 'Rowing'), + ('rower', 'VirtualRow'), + ('skierg', 'NordicSki'), + ('bike', 'Ride'), + ('bikeerg', 'VirtualRide'), + ('dynamic', 'Rowing'), + ('slides', 'Rowing'), + ('paddle', 'StandUpPaddling'), + ('snow', 'NordicSki'), + ('coastal', 'Rowing'), + ('c-boat', 'Rowing'), + ('churchboat', 'Rowing'), + ('Ride', 'Ride'), + ('Run', 'Run'), + ('NordicSki', 'NordicSki'), + ('Swim', 'Swim'), + ('Hike', 'Hike'), + ('Walk', 'Walk'), + ('Canoeing', 'Canoeing'), + ('Crossfit', 'Crossfit'), + ('StandUpPaddling', 'StandUpPaddling'), + ('IceSkate', 'IceSkate'), + ('WeightTraining', 'WeightTraining'), + ('InlineSkate', 'InlineSkate'), + ('Kayaking', 'Kayaking'), + ('Workout', 'Workout'), + ('Yoga', 'Yoga'), + ('other', 'Other'), +) + +intervalsmapping = {key: value for key, value in Reverse(intervalscollection)} + stcollection = ( ('water', 'Rowing'), ('rower', 'Rowing'), @@ -332,6 +367,9 @@ garminmappinginv = {value: key for key, value in Reverse( fitmappinginv = {value: key for key, value in Reverse( fitcollection) if value is not None} +intervalsmappinginv = {value: key for key, value in Reverse( + intervalscollection) if value is not None} + otwtypes = ( 'water', 'coastal', diff --git a/rowers/tasks.py b/rowers/tasks.py index 6fc8793a..36999b19 100644 --- a/rowers/tasks.py +++ b/rowers/tasks.py @@ -24,7 +24,7 @@ from rowers.courseutils import ( InvalidTrajectoryError ) from rowers.emails import send_template_email -from rowers.mytypes import fitmappinginv +from rowers.mytypes import intervalsmappinginv from rowers.nkimportutils import ( get_nk_summary, get_nk_allstats, get_nk_intervalstats, getdict, strokeDataToDf, add_workout_from_data @@ -3508,11 +3508,11 @@ def handle_intervals_getworkout(rower, intervalstoken, workoutid, debug=False, * title = 'Intervals workout' try: - workouttype = fitmappinginv[data['type']] - print(data['type']) + workouttype = intervalsmappinginv[data['type']] except KeyError: workouttype = 'water' + url = "https://intervals.icu/api/v1/activity/{workoutid}/fit-file".format(workoutid=workoutid) response = requests.get(url, headers=headers) @@ -3535,7 +3535,6 @@ def handle_intervals_getworkout(rower, intervalstoken, workoutid, debug=False, * duration = totaltime_sec_to_string(rowdata.duration) distance = rowdata.df[" Horizontal (meters)"].iloc[-1] except Exception as e: - print(e) return 0 uploadoptions = { @@ -3544,6 +3543,7 @@ def handle_intervals_getworkout(rower, intervalstoken, workoutid, debug=False, * 'boattype': '1x', 'workouttype': workouttype, 'file': fit_filename, + 'intervalsid': intervalsid, 'title': title, 'rpe': 0, 'notes': '', diff --git a/rowers/uploads.py b/rowers/uploads.py index 781271f7..b13881a9 100644 --- a/rowers/uploads.py +++ b/rowers/uploads.py @@ -146,6 +146,22 @@ def do_sync(w, options, quick=False): except KeyError: pass + do_icu_export = w.user.intervals_auto_export + try: + do_icu_export = options['upload_to_Intervals'] or do_icu_export + except KeyError: + pass + + try: + if options['intervalsid'] != 0 and options['intervalsid'] != '': # pragma: no cover + w.uploadedtointervals = options['intervalsid'] + # upload_to_icu = False + do_icu_export = False + w.save() + record = create_or_update_syncrecord(w.user, w, intervalsid=options['intervalsid']) + except KeyError: + pass + try: if options['nkid'] != 0 and options['nkid'] != '': # pragma: no cover w.uploadedtonk = options['nkid'] @@ -232,14 +248,29 @@ def do_sync(w, options, quick=False): except NoTokenError: # pragma: no cover id = 0 message = "Please connect to Strava first" - except: - e = sys.exc_info()[0] - t = time.localtime() - timestamp = time.strftime('%b-%d-%Y_%H%M', t) - with open('stravalog.log', 'a') as f: - f.write('\n') - f.write(timestamp) - f.write(str(e)) + except Exception as e: + dologging('stravalog.log', e) + + if do_icu_export: + intervals_integration = IntervalsIntegration(w.user.user) + try: + id = intervals_integration.workout_export(w) + dologging( + 'intervals.icu.log', + 'exporting workout {id} as {type}'.format( + id=w.id, + type=w.workouttype, + ) + ) + except NoTokenError: + id = 0 + message = "Please connect to Intervals.icu first" + except Exception as e: + dologging( + 'intervals.icu.log', + e + ) + do_st_export = w.user.sporttracks_auto_export From a0b13f3f3df973be7cf65474a8f4d21c457924d6 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Mon, 9 Dec 2024 21:53:34 +0100 Subject: [PATCH 4/7] import now behaving --- rowers/dataprep.py | 7 ++++ rowers/dataroutines.py | 57 ++++++++++++++++++++++++++++---- rowers/integrations/intervals.py | 17 ++++++++-- rowers/models.py | 6 ++-- rowers/mytypes.py | 1 + rowers/tasks.py | 2 +- rowers/views/importviews.py | 5 ++- 7 files changed, 80 insertions(+), 15 deletions(-) diff --git a/rowers/dataprep.py b/rowers/dataprep.py index 8d6ed877..827a9717 100644 --- a/rowers/dataprep.py +++ b/rowers/dataprep.py @@ -1572,6 +1572,13 @@ def new_workout_from_file(r, f2, # Get workout type from fit & tcx if (fileformat == 'fit'): # pragma: no cover workouttype = get_workouttype_from_fit(f2, workouttype=workouttype) + new_title = get_title_from_fit(f2) + if new_title: + title = new_title + new_notes = get_notes_from_fit(f2) + if new_notes: + notes = new_notes + # if (fileformat == 'tcx'): # workouttype_from_tcx = get_workouttype_from_tcx(f2,workouttype=workouttype) # if workouttype != 'rower' and workouttype_from_tcx not in mytypes.otwtypes: diff --git a/rowers/dataroutines.py b/rowers/dataroutines.py index 61282da7..d34a1a3e 100644 --- a/rowers/dataroutines.py +++ b/rowers/dataroutines.py @@ -1350,6 +1350,39 @@ def handle_nonpainsled(f2, fileformat, summary='', startdatetime='', empowerfirm # Create new workout from file and store it in the database # This routine should be used everywhere in views.py +def get_notes_from_fit(filename): + try: + fitfile = FitFile(filename, check_crc=False) + except FitHeaderError: # pragma: no cover + return '' + + records = fitfile.messages + notes = '' + for record in records: + if record.name == 'session': + try: + notes = ' '.join(record.get_values()['description'].split()) + except KeyError: + pass + + return notes + +def get_title_from_fit(filename): + try: + fitfile = FitFile(filename, check_crc=False) + except FitHeaderError: # pragma: no cover + return '' + + records = fitfile.messages + title = '' + for record in records: + if record.name == 'workout': + try: + title = ' '.join(record.get_values()['wkt_name'].split()) + except KeyError: + pass + + return title def get_workouttype_from_fit(filename, workouttype='water'): try: @@ -1359,17 +1392,27 @@ def get_workouttype_from_fit(filename, workouttype='water'): records = fitfile.messages fittype = 'rowing' + subsporttype = '' for record in records: - if record.name in ['sport', 'lap']: + if record.name in ['sport', 'lap','session']: try: fittype = record.get_values()['sport'].lower() + try: + subsporttype = record.get_values()['sub_sport'].lower() + except KeyError: + subsporttype = '' except (KeyError, AttributeError): # pragma: no cover - return 'water' - try: - workouttype = mytypes.fitmappinginv[fittype] - return workouttype - except KeyError: # pragma: no cover - return workouttype + pass + if subsporttype: + try: + workouttype = mytypes.fitmappinginv[subsporttype] + except KeyError: + pass + else: + try: + workouttype = mytypes.fitmappinginv[fittype] + except KeyError: + pass return workouttype diff --git a/rowers/integrations/intervals.py b/rowers/integrations/intervals.py index 185ef8d9..e1cea0fd 100644 --- a/rowers/integrations/intervals.py +++ b/rowers/integrations/intervals.py @@ -107,8 +107,20 @@ class IntervalsIntegration(SyncIntegration): def get_workout_list(self, *args, **kwargs) -> int: url = self.oauth_data['base_url'] + 'athlete/0/activities?' - startdate = timezone.now() - timedelta(days=365) + startdate = timezone.now() - timedelta(days=30) enddate = timezone.now() + timedelta(days=1) + startdatestring = kwargs.get("startdate","") + enddatestring = kwargs.get("enddate","") + + try: + startdate = arrow.get(startdatestring).datetime + except: + pass + try: + enddate = arrow.get(enddatestring).datetime + except: + pass + url += 'oldest=' + startdate.strftime('%Y-%m-%d') + '&newest=' + enddate.strftime('%Y-%m-%d') headers = { 'accept': '*/*', @@ -122,7 +134,6 @@ class IntervalsIntegration(SyncIntegration): data = response.json() known_interval_ids = get_known_ids(self.rower, 'intervalsid') - workouts = [] for item in data: @@ -145,7 +156,7 @@ class IntervalsIntegration(SyncIntegration): ress = dict(zip(keys, values)) workouts.append(ress) - + return workouts diff --git a/rowers/models.py b/rowers/models.py index 610cd019..259f9f55 100644 --- a/rowers/models.py +++ b/rowers/models.py @@ -3697,7 +3697,7 @@ class Workout(models.Model): uploadedtogarmin = models.BigIntegerField(default=0) uploadedtorp3 = models.BigIntegerField(default=0) uploadedtonk = models.BigIntegerField(default=0) - uploadedtointervals = models.BigIntegerField(default=0) + uploadedtointervals = models.CharField(default=None,null=True, max_length=100) forceunit = models.CharField(default='lbs', choices=( ('lbs', 'lbs'), @@ -3829,7 +3829,7 @@ class TombStone(models.Model): uploadedtosporttracks = models.BigIntegerField(default=0) uploadedtotp = models.BigIntegerField(default=0) uploadedtonk = models.BigIntegerField(default=0) - uploadedtointervals = models.BigIntegerField(default=0) + uploadedtointervals = models.CharField(default=None,null=True, max_length=100) @receiver(models.signals.pre_delete, sender=Workout) def create_tombstone_on_delete(sender, instance, **kwargs): @@ -3855,7 +3855,7 @@ class SyncRecord(models.Model): c2id = models.BigIntegerField(unique=True,null=True,default=None) tpid = models.BigIntegerField(unique=True,null=True,default=None) rp3id = models.BigIntegerField(unique=True,null=True,default=None) - intervalsid = models.BigIntegerField(unique=True, null=True, default=None) + intervalsid = models.CharField(unique=True, null=True, default=None, max_length=100) def save(self, *args, **kwargs): if self.workout: diff --git a/rowers/mytypes.py b/rowers/mytypes.py index b0586308..3f19525c 100644 --- a/rowers/mytypes.py +++ b/rowers/mytypes.py @@ -148,6 +148,7 @@ garminmapping = {key: value for key, value in Reverse(garmincollection)} fitcollection = ( ('water', 'rowing'), ('rower', 'rowing'), + ('rower', 'indoor_rowing'), ('skierg', 'cross_country_skiing'), ('bike', 'cycling'), ('bikeerg', 'cycling'), diff --git a/rowers/tasks.py b/rowers/tasks.py index 36999b19..a43a225d 100644 --- a/rowers/tasks.py +++ b/rowers/tasks.py @@ -3543,7 +3543,7 @@ def handle_intervals_getworkout(rower, intervalstoken, workoutid, debug=False, * 'boattype': '1x', 'workouttype': workouttype, 'file': fit_filename, - 'intervalsid': intervalsid, + 'intervalsid': workoutid, 'title': title, 'rpe': 0, 'notes': '', diff --git a/rowers/views/importviews.py b/rowers/views/importviews.py index 486155b1..a02527b0 100644 --- a/rowers/views/importviews.py +++ b/rowers/views/importviews.py @@ -471,7 +471,10 @@ def workout_import_view(request, source='c2'): try: tdict = dict(request.POST.lists()) ids = tdict['workoutid'] - nkids = [int(id) for id in ids] + try: + nkids = [int(id) for id in ids] + except ValueError: + nkids = ids for nkid in nkids: try: _ = integration.get_workout(nkid, startdate=startdate, enddate=enddate) From d93f5de8d16f183d2af5d92b681946edf242da9b Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Mon, 9 Dec 2024 22:44:06 +0100 Subject: [PATCH 5/7] export to intervals.icu --- rowers/integrations/intervals.py | 92 +++++++++++++++++++++++++++++- rowers/templates/menu_workout.html | 14 +++++ 2 files changed, 104 insertions(+), 2 deletions(-) diff --git a/rowers/integrations/intervals.py b/rowers/integrations/intervals.py index e1cea0fd..2669907f 100644 --- a/rowers/integrations/intervals.py +++ b/rowers/integrations/intervals.py @@ -100,10 +100,98 @@ class IntervalsIntegration(SyncIntegration): return token def createworkoutdata(self, w, *args, **kwargs) -> str: - return NotImplemented + dozip = kwargs.get('dozip', True) + filename = w.csvfilename + try: + row = rowingdata(csvfile=filename) + except IOError: # pragma: no cover + data = dataprep.read_df_sql(w.id) + try: + datalength = len(data) + except AttributeError: + datalength = 0 + + if datalength == 0: + data.rename(columns=columndict, inplace=True) + _ = data.to_csv(w.csvfilename+'.gz', index_label='index', compression='gzip') + try: + row = rowingdata(csvfile=filename) + except IOError: # pragma: no cover + return '' # pragma: no cover + else: + return '' + + tcxfilename = w.csvfilename[:-4] + '.tcx' + try: + newnotes = w.notes + '\n from'+w.workoutsource+' via rowsandall.com' + except TypeError: + newnotes = 'from'+w.workoutsource+' via rowsandall.com' + + row.exporttotcx(tcxfilename, notes=newnotes) + if dozip: + gzfilename = tcxfilename + '.gz' + try: + with open(tcxfilename, 'rb') as inF: + s = inF.read() + with gzip.GzipFile(gzfilename, 'wb') as outF: + outF.write(s) + try: + os.remove(tcxfilename) + except WindowsError: # pragma: no cover + pass + except FileNotFoundError: + return '' + + return gzfilename + + return tcxfilename + + def workout_export(self, workout, *args, **kwargs) -> str: - return NotImplemented + token = self.open() + + filename = self.createworkoutdata(workout) + if not filename: + return 0 + + params = { + 'name': workout.name, + 'description': workout.notes, + } + + + authorizationstring = str('Bearer ' + token) + # headers with authorization string and content type multipart/form-data + headers = { + 'Authorization': authorizationstring, + } + + url = "https://intervals.icu/api/v1/athlete/{athleteid}/activities".format(athleteid=0) + + with open(filename, 'rb') as f: + files = {'file': f} + response = requests.post(url, params=params, headers=headers, files=files) + + if response.status_code not in [200, 201]: + dologging('intervals.icu.log', response.reason) + return 0 + + id = response.json()['id'] + # set workout type to workouttype + url = "https://intervals.icu/api/v1/activity/{activityid}".format(activityid=id) + + thetype = mytypes.intervalsmapping[workout.workouttype] + response = requests.put(url, headers=headers, json={'type': thetype}) + if response.status_code not in [200, 201]: + return 0 + + workout.uploadedtointervals = id + workout.save() + + os.remove(filename) + + return id def get_workout_list(self, *args, **kwargs) -> int: url = self.oauth_data['base_url'] + 'athlete/0/activities?' diff --git a/rowers/templates/menu_workout.html b/rowers/templates/menu_workout.html index f61797f6..ea138baf 100644 --- a/rowers/templates/menu_workout.html +++ b/rowers/templates/menu_workout.html @@ -231,6 +231,20 @@ {% endif %} +
  • + {% if workout.uploadedtointervals and workout.uploadedtointervals != '0' %} + + Intervals.icu + + {% elif user.rower.intervals_token == None or user.rower.intervals_token == '' %} + + Connect to Intervals.icu + + {% else %} + + Intervals.icu + + {% endif %}
  • CSV From fc52491e8a85b75f06e78896b392ebc8a331f18b Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Tue, 10 Dec 2024 08:00:41 +0100 Subject: [PATCH 6/7] fix --- rowers/integrations/c2.py | 2 +- rowers/integrations/nk.py | 2 +- rowers/integrations/rp3.py | 2 +- rowers/integrations/strava.py | 2 +- rowers/integrations/trainingpeaks.py | 2 +- rowers/tests/testdata/testdata.tcx.gz | Bin 4002 -> 4001 bytes 6 files changed, 5 insertions(+), 5 deletions(-) diff --git a/rowers/integrations/c2.py b/rowers/integrations/c2.py index 2a4a57ab..c2d21332 100644 --- a/rowers/integrations/c2.py +++ b/rowers/integrations/c2.py @@ -63,7 +63,7 @@ class C2Integration(SyncIntegration): 'client_id': C2_CLIENT_ID, 'client_secret': C2_CLIENT_SECRET, 'redirect_uri': C2_REDIRECT_URI, - 'autorization_uri': "https://log.concept2.com/oauth/authorize", + 'authorization_uri': "https://log.concept2.com/oauth/authorize", 'content_type': 'application/x-www-form-urlencoded', 'tokenname': 'c2token', 'refreshtokenname': 'c2refreshtoken', diff --git a/rowers/integrations/nk.py b/rowers/integrations/nk.py index 960b2f51..a26a7cc9 100644 --- a/rowers/integrations/nk.py +++ b/rowers/integrations/nk.py @@ -35,7 +35,7 @@ class NKIntegration(SyncIntegration): 'client_id': NK_CLIENT_ID, 'client_secret': NK_CLIENT_SECRET, 'redirect_uri': NK_REDIRECT_URI, - 'autorization_uri': NK_OAUTH_LOCATION+"/oauth/authorize", + 'authorization_uri': NK_OAUTH_LOCATION+"/oauth/authorize", 'content_type': 'application/json', 'tokenname': 'nktoken', 'refreshtokenname': 'nkrefreshtoken', diff --git a/rowers/integrations/rp3.py b/rowers/integrations/rp3.py index 245c7615..bdbae35e 100644 --- a/rowers/integrations/rp3.py +++ b/rowers/integrations/rp3.py @@ -30,7 +30,7 @@ class RP3Integration(SyncIntegration): 'client_id': RP3_CLIENT_ID, 'client_secret': RP3_CLIENT_SECRET, 'redirect_uri': RP3_REDIRECT_URI, - 'autorization_uri': "https://rp3rowing-app.com/oauth/authorize?", + 'authorization_uri': "https://rp3rowing-app.com/oauth/authorize?", 'content_type': 'application/x-www-form-urlencoded', # 'content_type': 'application/json', 'tokenname': 'rp3token', diff --git a/rowers/integrations/strava.py b/rowers/integrations/strava.py index b78281a0..8df2cb31 100644 --- a/rowers/integrations/strava.py +++ b/rowers/integrations/strava.py @@ -89,7 +89,7 @@ class StravaIntegration(SyncIntegration): 'client_id': STRAVA_CLIENT_ID, 'client_secret': STRAVA_CLIENT_SECRET, 'redirect_uri': STRAVA_REDIRECT_URI, - 'autorization_uri': "https://www.strava.com/oauth/authorize", + 'authorization_uri': "https://www.strava.com/oauth/authorize", 'content_type': 'application/json', 'tokenname': 'stravatoken', 'refreshtokenname': 'stravarefreshtoken', diff --git a/rowers/integrations/trainingpeaks.py b/rowers/integrations/trainingpeaks.py index 14e3307a..404801de 100644 --- a/rowers/integrations/trainingpeaks.py +++ b/rowers/integrations/trainingpeaks.py @@ -41,7 +41,7 @@ class TPIntegration(SyncIntegration): 'client_id': TP_CLIENT_ID, 'client_secret': TP_CLIENT_SECRET, 'redirect_uri': TP_REDIRECT_URI, - 'autorization_uri': "https://oauth.trainingpeaks.com/oauth/authorize?", + 'authorization_uri': "https://oauth.trainingpeaks.com/oauth/authorize?", 'content_type': 'application/x-www-form-urlencoded', 'tokenname': 'tptoken', 'refreshtokenname': 'tprefreshtoken', diff --git a/rowers/tests/testdata/testdata.tcx.gz b/rowers/tests/testdata/testdata.tcx.gz index 1c63fdbf311e81cba18bddbb4e55981700a69cb1..44ca7da185282a4134935f7eb23bb2dcbe772b58 100644 GIT binary patch literal 4001 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 Ib;N)H0QPu4hX4Qo From 5db98f034f7a78e950c16b00a034f445938f0b2f Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Tue, 10 Dec 2024 17:44:44 +0100 Subject: [PATCH 7/7] tested --- rowers/tests/testdata/testdata.tcx.gz | Bin 4001 -> 4000 bytes 1 file changed, 0 insertions(+), 0 deletions(-) diff --git a/rowers/tests/testdata/testdata.tcx.gz b/rowers/tests/testdata/testdata.tcx.gz index 44ca7da185282a4134935f7eb23bb2dcbe772b58..4ab2f8bd4647adef840c754fefc2d20fa0e34e9e 100644 GIT binary patch literal 4000 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 GfB^t2@GX7- literal 4001 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