diff --git a/rowers/dataprep.py b/rowers/dataprep.py index 8d6ed877..a824f571 100644 --- a/rowers/dataprep.py +++ b/rowers/dataprep.py @@ -217,6 +217,9 @@ def workout_goldmedalstandard(workout, reset=False): def check_marker(workout): r = workout.user + if workout.workoutsource == 'strava': + return None + gmstandard, gmseconds = workout_goldmedalstandard(workout) if gmseconds < 60: return None @@ -369,8 +372,20 @@ def workout_summary_to_df( return df -def resample(id, r, parent, overwrite='copy'): +def resample(id, r, parent, overwrite=False): data, row = getrowdata_db(id=id) + rowdata = rrdata(csvfile=parent.csvfilename).df + # drop all columns except ' latitude' and ' longitude' and 'TimeStamp (sec)' from rowdata + allowedcolumns = [' latitude', ' longitude', 'TimeStamp (sec)'] + rowdata = rowdata.filter(allowedcolumns) + rowdata.rename(columns={'TimeStamp (sec)': 'time'}, inplace=True) + rowdata['time'] = (rowdata['time']-rowdata.loc[0,'time'])*1000. + rowdata.set_index('time', inplace=True) + data.set_index('time', inplace=True) + rowdata_interpolated = rowdata.reindex(data.index.union(rowdata.index)).interpolate('index') + data = data.merge(rowdata_interpolated, left_index=True, right_index=True, how='left') + data = data.reset_index() + messages = [] # resample @@ -393,7 +408,7 @@ def resample(id, r, parent, overwrite='copy'): data['pace'] = data['pace'] / 1000. data['time'] = data['time'] / 1000. - if overwrite == 'overwrite': + if overwrite == True: # remove CP data try: cpfile = 'media/cpdata_{id}.parquet.gz'.format(id=parent.id) @@ -1304,8 +1319,11 @@ def save_workout_database(f2, r, dosmooth=True, workouttype='rower', if makeprivate: # pragma: no cover privacy = 'hidden' - else: + elif workoutsource != 'strava': privacy = 'visible' + else: + privacy = 'hidden' + # checking for inf values @@ -1572,6 +1590,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 9360b1b7..a47521d5 100644 --- a/rowers/dataroutines.py +++ b/rowers/dataroutines.py @@ -1289,10 +1289,10 @@ def parsenonpainsled(fileformat, f2, summary, startdatetime='', empowerfirmware= # handle FIT if (fileformat == 'fit'): # pragma: no cover try: - s = fitsummarydata(f2) + s = FitSummaryData(f2) s.setsummary() summary = s.summarytext - except: + except Exception as e: pass hasrecognized = True @@ -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,16 +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] - 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 @@ -1605,9 +1649,13 @@ def read_data(columns, ids=[], doclean=True, workstrokesonly=True, debug=False, existing_columns = [col for col in columns if col in datadf.columns] datadf = datadf.select(existing_columns) except (ShapeError, SchemaError): - data = [ - df.select(columns) - for df in data] + try: + data = [ + df.select(columns) + for df in data] + except ColumnNotFoundError: + existing_columns = [col for col in columns if col in df.columns] + df = df.select(existing_columns) # float columns floatcolumns = [] @@ -1642,14 +1690,19 @@ def read_data(columns, ids=[], doclean=True, workstrokesonly=True, debug=False, ] except ComputeError: pass + except ColumnNotFoundError: + pass try: datadf = pl.concat(data) except SchemaError: - data = [ - df.with_columns(cs.integer().cast(pl.Float64)) for df in data - ] - datadf = pl.concat(data) + try: + data = [ + df.with_columns(cs.integer().cast(pl.Float64)) for df in data + ] + datadf = pl.concat(data) + except ShapeError: + return pl.DataFrame() diff --git a/rowers/forms.py b/rowers/forms.py index fa3f1b4b..506f900f 100644 --- a/rowers/forms.py +++ b/rowers/forms.py @@ -67,13 +67,12 @@ class FlexibleDecimalField(forms.DecimalField): class ResampleForm(forms.Form): - resamplechoices = ( - ('overwrite', 'Overwrite Workout'), - ('copy', 'Create a Duplicate Workout') - ) - + # add resamplechoice field, the result is a True or False boolean, labels are "overwrite" and "create copy" resamplechoice = forms.ChoiceField( - initial='copy', choices=resamplechoices, label='Copy behavior') + required=True, + choices=((True, 'overwrite'), (False, 'create copy')), + label='Resample choice', + widget=forms.RadioSelect) class TrainingZonesForm(forms.Form): @@ -554,6 +553,9 @@ class UploadOptionsForm(forms.Form): upload_to_TrainingPeaks = forms.BooleanField(initial=False, required=False, label='Export to TrainingPeaks') + upload_to_Intervals = forms.BooleanField(initial=False, + required=False, + label='Export to Intervals') # do_physics = forms.BooleanField(initial=False,required=False,label='Power Estimate (OTW)') makeprivate = forms.BooleanField(initial=False, required=False, label='Make Workout Private') @@ -579,6 +581,11 @@ class UploadOptionsForm(forms.Form): races = VirtualRace.objects.filter( registration_closure__gt=timezone.now()) + # set upload_to_X based on r.X_auto_export + for field in ['C2', 'Strava', 'SportTracks', 'TrainingPeaks', 'Intervals']: + if getattr(r, field.lower()+'_auto_export') and r.rowerplan in ['pro', 'plan','coach']: + self.fields['upload_to_'+field].initial = True + registrations = IndoorVirtualRaceResult.objects.filter( race__in=races, userid=r.id) @@ -662,6 +669,10 @@ class TeamUploadOptionsForm(forms.Form): upload_to_TrainingPeaks = forms.BooleanField(initial=False, required=False, label='Export to TrainingPeaks') + + upload_to_Intervals = forms.BooleanField(initial=False, + required=False, + label='Export to TrainingPeaks') # do_physics = forms.BooleanField(initial=False,required=False,label='Power Estimate (OTW)') makeprivate = forms.BooleanField(initial=False, required=False, label='Make Workout Private') 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/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/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 new file mode 100644 index 00000000..66fdb0d5 --- /dev/null +++ b/rowers/integrations/intervals.py @@ -0,0 +1,319 @@ +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 +from rowers.tasks import handle_intervals_getworkout + +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 +import rowers.dataprep as dataprep +from rowers.opaque import encoder + +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) + + +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/api/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_url': 'https://intervals.icu/api/v1/', + 'grant_type': 'refresh_token', + 'headers': headers, + 'scope': 'ACTIVITY:WRITE, LIBRARY:READ', + } + + def get_token(self, 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' + + 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, self).open(*args, **kwargs) + return token + + def createworkoutdata(self, w, *args, **kwargs) -> str: + dozip = kwargs.get('dozip', True) + # resample if wanted by user, not tested + if w.user.intervals_resample_to_1s: + datadf, id, msgs = dataprep.resample( + w.id, w.user, w, overwrite=False + ) + w_resampled = Workout.objects.get(id=id) + filename = w_resampled.csvfilename + else: + w_resampled = None + 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' + + if w.user.intervals_resample_to_1s and w_resampled: + w_resampled.delete() + row.exporttotcx(tcxfilename, notes=newnotes, sport=mytypes.intervalsmapping[w.workouttype]) + 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: + token = self.open() + dologging('intervals.icu.log', "Exporting workout {id}".format(id=workout.id)) + + filename = self.createworkoutdata(workout) + if not filename: + return 0 + + params = { + 'name': workout.name, + 'description': workout.notes, + 'external_id': encoder.encode_hex(workout.id), + } + + + 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) + + dologging('intervals.icu.log', "Exported workout {id}".format(id=workout.id)) + + return id + + def get_workout_list(self, *args, **kwargs) -> int: + url = self.oauth_data['base_url'] + 'athlete/0/activities?' + 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': '*/*', + '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: + try: + 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) + except KeyError: + dologging('intervals.icu.log', item) + + + return workouts + + + def get_workout(self, id, *args, **kwargs) -> int: + _ = self.open() + r = self.rower + + record = create_or_update_syncrecord(r, None, intervalsid=id) + + _ = myqueue(queuehigh, + handle_intervals_getworkout, + self.rower, + self.rower.intervals_token, + id) + + return 1 + + def get_workouts(self, *args, **kwargs): + startdate = timezone.now() - timedelta(days=7) + 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 + + count = 0 + workouts = self.get_workout_list(startdate=startdate, enddate=enddate) + for workout in workouts: + if workout['new'] == 'NEW': + self.get_workout(workout['id']) + count +=1 + + return count + + 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/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..8c3bb595 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', @@ -214,7 +214,7 @@ class StravaIntegration(SyncIntegration): def get_workout(self, id, *args, **kwargs) -> int: try: _ = self.open() - except NoTokenError("Strava error"): + except NoTokenError: return 0 record = create_or_update_syncrecord(self.rower, None, stravaid=id) diff --git a/rowers/integrations/trainingpeaks.py b/rowers/integrations/trainingpeaks.py index 14e3307a..93a73089 100644 --- a/rowers/integrations/trainingpeaks.py +++ b/rowers/integrations/trainingpeaks.py @@ -15,6 +15,7 @@ from rowingdata import rowingdata from rowers.rower_rules import is_workout_user import time from django_rq import job +from rowers.mytypes import tpmapping from rowers.tasks import check_tp_workout_id, handle_workout_tp_upload @@ -41,7 +42,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', @@ -66,7 +67,10 @@ class TPIntegration(SyncIntegration): except TypeError: newnotes = 'from '+w.workoutsource+' via rowsandall.com' - row.exporttotcx(tcxfilename, notes=newnotes) + try: + row.exporttotcx(tcxfilename, notes=newnotes, sport=tpmapping[w.workouttype]) + except KeyError: + row.exporttotcx(tcxfilename, notes=newnotes, sport='other') return tcxfilename diff --git a/rowers/interactiveplots.py b/rowers/interactiveplots.py index 4e4d4c74..2f0d2a5d 100644 --- a/rowers/interactiveplots.py +++ b/rowers/interactiveplots.py @@ -550,7 +550,7 @@ def goldmedalscorechart(user, startdate=None, enddate=None): workouts = Workout.objects.filter(user=user.rower, date__gte=startdate, date__lte=enddate, workouttype__in=mytypes.rowtypes, - duplicate=False).order_by('date') + duplicate=False).order_by('date').exclude(workoutsource='strava') markerworkouts = workouts.filter(rankingpiece=True) outids = [w.id for w in markerworkouts] diff --git a/rowers/management/commands/getsyncids.py b/rowers/management/commands/getsyncids.py index a2f9bf75..806a7218 100644 --- a/rowers/management/commands/getsyncids.py +++ b/rowers/management/commands/getsyncids.py @@ -24,7 +24,8 @@ class Command(BaseCommand): record.sporttracksid = w.uploadedtosporttracks if w.uploadedtoc2: record.c2id = w.uploadedtoc2 - + if w.uploadedtointervals: + record.intervalsid = w.uploadedtointervals try: record.save() except IntegrityError: @@ -52,7 +53,8 @@ class Command(BaseCommand): record.sporttracksid = w.uploadedtosporttracks if w.uploadedtoc2: record.c2id = w.uploadedtoc2 - + if w.uploadedtointervals: + record.intervalsid = w.uploadedtointervals try: record.save() except IntegrityError: diff --git a/rowers/management/commands/processemail.py b/rowers/management/commands/processemail.py index 6d12ff71..be02ab17 100644 --- a/rowers/management/commands/processemail.py +++ b/rowers/management/commands/processemail.py @@ -117,5 +117,16 @@ class Command(BaseCommand): lines = traceback.format_exception(exc_type, exc_value, exc_traceback) dologging('processemail.log', ''.join('!! ' + line for line in lines)) + rowers = Rower.objects.filter(intervals_auto_import=True) + for r in rowers: + try: + if user_is_not_basic(r.user) or user_is_coachee(r.user): + intervals_integration = IntervalsIntegration(r.user) + _ = intervals_integration.get_workouts() + except: + exc_type, exc_value, exc_traceback = sys.exc_info() + lines = traceback.format_exception(exc_type, exc_value, exc_traceback) + dologging('processemail.log', ''.join('!! ' + line for line in lines)) + self.stdout.write(self.style.SUCCESS( 'Successfully processed email attachments')) diff --git a/rowers/management/commands/setstravaprivate.py b/rowers/management/commands/setstravaprivate.py new file mode 100644 index 00000000..c90c8f3f --- /dev/null +++ b/rowers/management/commands/setstravaprivate.py @@ -0,0 +1,38 @@ +#!/srv/venv/bin/python +import sys +import os +from django.core.management.base import BaseCommand, CommandError +from django.conf import settings + +import time + +from rowers.models import ( + Workout, User, Rower, WorkoutForm, + RowerForm, GraphImage, AdvancedWorkoutForm) +from django.core.files.base import ContentFile + +from rowsandall_app.settings import BASE_DIR + +from rowers.dataprep import * + +# If you find a solution that does not need the two paths, please comment! +sys.path.append('$path_to_root_of_project$') +sys.path.append('$path_to_root_of_project$/$project_name$') + +os.environ['DJANGO_SETTINGS_MODULE'] = '$project_name$.settings' + + +class Command(BaseCommand): + def handle(self, *args, **options): + # find all Workout instances with uploadedtostrava not 0 or None, workoutsource not 'strava' + workouts = Workout.objects.filter(uploadedtostrava__gt=0) + # report the number of workouts found to the console + self.stdout.write(self.style.SUCCESS('Found {} Strava workouts.'.format(workouts.count()))) + # set workout.privacy to hidden and workout.workoutsource to 'strava, report percentage complete to console' + for workout in workouts: + workout.privacy = 'hidden' + workout.workoutsource = 'strava' + workout.save() + self.stdout.write(self.style.SUCCESS('Set workout {} private.'.format(workout.id))) + + self.stdout.write(self.style.SUCCESS('Successfully set all Strava data private.')) diff --git a/rowers/models.py b/rowers/models.py index 857a2680..c67ad31c 100644 --- a/rowers/models.py +++ b/rowers/models.py @@ -21,7 +21,7 @@ from django.forms import ModelForm from django.dispatch import receiver from django.forms.widgets import SplitDateTimeWidget, SelectDateWidget from django.forms.formsets import BaseFormSet - +from django.db.models.signals import post_save from django.contrib.admin.widgets import AdminDateWidget, AdminTimeWidget, AdminSplitDateTime import os @@ -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,9 @@ 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) + intervals_resample_to_1s = models.BooleanField(default=False, verbose_name='Resample to 1s on export') sporttrackstoken = models.CharField( default='', max_length=200, blank=True, null=True) sporttrackstokenexpirydate = models.DateTimeField(blank=True, null=True) @@ -1238,8 +1241,12 @@ class Rower(models.Model): strava_auto_export = models.BooleanField(default=False) strava_auto_import = models.BooleanField(default=False) - strava_auto_delete = models.BooleanField(default=False) + strava_auto_delete = models.BooleanField(default=True) + intervals_token = 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'), ('hidden', 'Hidden'), @@ -1248,6 +1255,8 @@ class Rower(models.Model): getemailnotifications = models.BooleanField(default=False, verbose_name='Receive email notifications') + imports_are_private = models.BooleanField(default=False, verbose_name='Make imports private by default') + # Friends/Team friends = models.ManyToManyField("self", blank=True) mycoachgroup = models.ForeignKey( @@ -1434,9 +1443,26 @@ parchoicesy1 = list(sorted(favchartlabelsy1.items(), key=lambda x: x[1])) parchoicesy2 = list(sorted(favchartlabelsy2.items(), key=lambda x: x[1])) parchoicesx = list(sorted(favchartlabelsx.items(), key=lambda x: x[1])) +# special filter for workouts to exclude strava workouts by default +class WorkoutQuerySet(models.QuerySet): + def filter(self, *args, exclude_strava=True, **kwargs): + queryset = super().filter(*args, **kwargs) + if exclude_strava: + queryset = queryset.exclude(workoutsource='strava') + + return queryset + + def get(self, *args, **kwargs): + queryset = self + + return super().get(*args, **kwargs) + + +class WorkoutManager(models.Manager): + def get_queryset(self): + return WorkoutQuerySet(self.model, using=self._db) + # Saving a chart as a favorite chart - - class FavoriteChart(models.Model): workouttypechoices = [ ('ote', 'Erg/SkiErg'), @@ -3691,6 +3717,7 @@ class Workout(models.Model): uploadedtogarmin = models.BigIntegerField(default=0) uploadedtorp3 = models.BigIntegerField(default=0) uploadedtonk = models.BigIntegerField(default=0) + uploadedtointervals = models.CharField(default=None,null=True, max_length=100) forceunit = models.CharField(default='lbs', choices=( ('lbs', 'lbs'), @@ -3715,6 +3742,9 @@ class Workout(models.Model): default=False, verbose_name='Duplicate Workout') impeller = models.BooleanField(default=False, verbose_name='Impeller') + # attach the WorkoutManager + #objects = WorkoutManager() + def url(self): str = '/rowers/workout/{id}/'.format( id=encoder.encode_hex(self.id) @@ -3752,6 +3782,15 @@ class Workout(models.Model): super(Workout, self).save(*args, **kwargs) + @classmethod + def post_create(cls, sender, instance, created, *args, **kwargs): + if created: + user = instance.user + if user.imports_are_private: + instance.privacy = 'hidden' + instance.save() + + def __str__(self): try: @@ -3810,6 +3849,8 @@ class Workout(models.Model): return stri +post_save.connect(Workout.post_create, sender=Workout) + class WorkoutRPEForm(ModelForm): class Meta: model = Workout @@ -3822,6 +3863,7 @@ class TombStone(models.Model): uploadedtosporttracks = models.BigIntegerField(default=0) uploadedtotp = models.BigIntegerField(default=0) uploadedtonk = 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): @@ -3830,7 +3872,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() @@ -3846,6 +3889,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.CharField(unique=True, null=True, default=None, max_length=100) def save(self, *args, **kwargs): if self.workout: @@ -3861,7 +3905,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( @@ -4547,9 +4591,90 @@ class RowerExportForm(ModelForm): 'strava_auto_import', 'strava_auto_delete', 'trainingpeaks_auto_export', - 'rp3_auto_import' + 'rp3_auto_import', + 'intervals_auto_import', + 'intervals_auto_export', + 'intervals_resample_to_1s', + 'imports_are_private' ] +class RowerPrivateImportForm(ModelForm): + class Meta: + model = Rower + fields = [ + 'imports_are_private' + ] + +class RowerExportFormStrava(ModelForm): + class Meta: + model = Rower + fields = [ + 'stravaexportas', + 'strava_auto_export', + 'strava_auto_import', + 'strava_auto_delete', + ] + +class RowerExportFormIntervals(ModelForm): + class Meta: + model = Rower + fields = [ + 'intervals_auto_import', + 'intervals_auto_export', + 'intervals_resample_to_1s', + ] + +class RowerExportFormGarmin(ModelForm): + class Meta: + model = Rower + fields = [ + 'garminactivity', + ] + +class RowerExportFormPolar(ModelForm): + class Meta: + model = Rower + fields = [ + 'polar_auto_import', + ] + +class RowerExportFormConcept2(ModelForm): + class Meta: + model = Rower + fields = [ + 'c2_auto_export', + 'c2_auto_import', + ] + +class RowerExportFormSportTracks(ModelForm): + class Meta: + model = Rower + fields = [ + 'sporttracks_auto_export', + ] + +class RowerExportFormTrainingPeaks(ModelForm): + class Meta: + model = Rower + fields = [ + 'trainingpeaks_auto_export', + ] + +class RowerExportFormRP3(ModelForm): + class Meta: + model = Rower + fields = [ + 'rp3_auto_import', + ] + +class RowerExportFormNK(ModelForm): + class Meta: + model = Rower + fields = [ + 'nk_auto_import' + ] + + # Simple form to set rower's Functional Threshold Power class SimpleRowerPowerForm(ModelForm): otwftp = forms.IntegerField(initial=0,required=True, label='FTP on water') diff --git a/rowers/mytypes.py b/rowers/mytypes.py index afc90c8b..194793fe 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'), @@ -180,6 +181,74 @@ fitcollection = ( fitmapping = {key: value for key, value in Reverse(fitcollection)} +tcxcollection = ( + ('water', 'Rowing'), + ('rower', 'Rowing'), + ('skierg', 'CrossCountrySkiing'), + ('bike', 'Biking'), + ('bikeerg', 'Biking'), + ('dynamic', 'Rowing'), + ('slides', 'Rowing'), + ('paddle', 'Other'), + ('snow', 'CrossCountrySkiing'), + ('coastal', 'Rowing'), + ('c-boat', 'Rowing'), + ('churchboat', 'Rowing'), + ('Ride', 'Biking'), + ('Run', 'Running'), + ('NordicSki', 'CrossCountrySkiing'), + ('Swim', 'Swimming'), + ('Hike', 'Hiking'), + ('Walk', 'Walking'), + ('Canoeing', 'Other'), + ('Crossfit', 'Other'), + ('StandUpPaddling', 'Other'), + ('IceSkate', 'Other'), + ('WeightTraining', 'Other'), + ('InlineSkate', 'Other'), + ('Kayaking', 'Other'), + ('Workout', 'Other'), + ('Yoga', 'Other'), + ('other', 'Other'), +) + +tcxmapping = {key: value for key, value in Reverse(tcxcollection)} + +tcxmappinginv = {value: key for key, value in Reverse(tcxcollection) if value is not None} + +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 +401,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/plannedsessions.py b/rowers/plannedsessions.py index d9e6add5..1918d750 100644 --- a/rowers/plannedsessions.py +++ b/rowers/plannedsessions.py @@ -1597,13 +1597,28 @@ def add_workout_fastestrace(ws, race, r, recordid=0, doregister=False): enddatetime ) + # from ws, remove any w where w.workoutsource = 'strava'. For each removal add an error "strava workout not permitted" to the errors list and if there are no workouts left, return 0, comments, errors, 0 + ws2 = [] + for w in ws: + if w.workoutsource != 'strava': + ws2.append(w) + else: + errors.append('Strava workouts are not permitted') + + ws = ws2 + + if len(ws) == 0: + return result, comments, errors, 0 + ids = [w.id for w in ws] ids = list(set(ids)) + if len(ids) > 1 and race.sessiontype in ['test', 'coursetest', 'race', 'indoorrace', 'fastest_time', 'fastest_distance']: # pragma: no cover errors.append('For tests, you can only attach one workout') return result, comments, errors, 0 + if r.birthdate: age = calculate_age(r.birthdate) else: # pragma: no cover @@ -1759,6 +1774,19 @@ def add_workout_indoorrace(ws, race, r, recordid=0, doregister=False): enddatetime ) + # from ws, remove any w where w.workoutsource = 'strava'. For each removal add an error "strava workout not permitted" to the errors list and if there are no workouts left, return 0, comments, errors, 0 + ws2 = [] + for w in ws: + if w.workoutsource != 'strava': + ws2.append(w) + else: + errors.append('Strava workouts are not permitted') + + ws = ws2 + + if len(ws) == 0: + return result, comments, errors, 0 + # check if all sessions have same date dates = [w.date for w in ws] if (not all(d == dates[0] for d in dates)) and race.sessiontype not in ['challenge', 'cycletarget']: # pragma: no cover @@ -1906,6 +1934,19 @@ def add_workout_race(ws, race, r, splitsecond=0, recordid=0, doregister=False): enddatetime ) + # from ws, remove any w where w.workoutsource = 'strava'. For each removal add an error "strava workout not permitted" to the errors list and if there are no workouts left, return 0, comments, errors, 0 + ws2 = [] + for w in ws: + if w.workoutsource != 'strava': + ws2.append(w) + else: + errors.append('Strava workouts are not permitted') + + ws = ws2 + + if len(ws) == 0: + return result, comments, errors, 0 + # check if all sessions have same date dates = [w.date for w in ws] if (not all(d == dates[0] for d in dates)) and race.sessiontype not in ['challenge', 'cycletarget']: # pragma: no cover diff --git a/rowers/rower_rules.py b/rowers/rower_rules.py index c8a14c14..68529023 100644 --- a/rowers/rower_rules.py +++ b/rowers/rower_rules.py @@ -451,6 +451,11 @@ def is_workout_user(user, workout): except AttributeError: # pragma: no cover return False + if workout.privacy == 'hidden': + return user == workout.user.user + if workout.workoutsource == 'strava': + return user == workout.user.user + if workout.user == r: return True @@ -458,6 +463,9 @@ def is_workout_user(user, workout): # check if user is in same team as owner of workout +@rules.predicate +def workout_is_strava(workout): + return workout.workoutsource == 'strava' @rules.predicate def is_workout_team(user, workout): @@ -469,6 +477,11 @@ def is_workout_team(user, workout): except AttributeError: # pragma: no cover return False + if workout.privacy == 'hidden': + return user == workout.user.user + if workout.workoutsource == 'strava': + return user == workout.user.user + if workout.user == r: return True @@ -479,7 +492,9 @@ def is_workout_team(user, workout): @rules.predicate def can_view_workout(user, workout): - if workout.privacy != 'private': + if workout.workoutsource == 'strava': + return user == workout.user.user + if workout.privacy not in ('hidden', 'private'): return True if user.is_anonymous: # pragma: no cover return False diff --git a/rowers/tasks.py b/rowers/tasks.py index bad2cc3c..ebadcfee 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 intervalsmappinginv 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 = 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) + + 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: + return 0 + + uploadoptions = { + 'secret': UPLOAD_SERVICE_SECRET, + 'user': rower.user.id, + 'boattype': '1x', + 'workouttype': workouttype, + 'file': fit_filename, + 'intervalsid': workoutid, + '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): @@ -3626,7 +3695,8 @@ def handle_c2_async_workout(alldata, userid, c2token, c2id, delaysec, code=uuid4().hex[:16], c2id=c2id) startdatetime, starttime, workoutdate, duration, starttimeunix, timezone = utils.get_startdatetime_from_c2data( - data) + data + ) s = 'Time zone {timezone}, startdatetime {startdatetime}, duration {duration}'.format( timezone=timezone, startdatetime=startdatetime, @@ -3686,6 +3756,7 @@ def handle_c2_async_workout(alldata, userid, c2token, c2id, delaysec, strokelength = np.zeros(nr_rows) dist2 = 0.1*strokedata.loc[:, 'd'] + cumdist, intervals = make_cumvalues(dist2) try: spm = strokedata.loc[:, 'spm'] @@ -3727,7 +3798,7 @@ def handle_c2_async_workout(alldata, userid, c2token, c2id, delaysec, ' lapIdx': lapidx, ' WorkoutState': 4, ' ElapsedTime (sec)': seconds, - 'cum_dist': dist2 + 'cum_dist': cumdist }) df.sort_values(by='TimeStamp (sec)', ascending=True) diff --git a/rowers/templates/developers.html b/rowers/templates/developers.html index ff0f01d0..3d966f82 100644 --- a/rowers/templates/developers.html +++ b/rowers/templates/developers.html @@ -8,7 +8,7 @@

Using the REST API

-

We are building a REST API which will allow you to post and +

We have a REST API which will allow you to post and receive stroke data from the site directly.

-

The REST API is a work in progress. We are open to improvement +

We are open to improvement suggestions (provided they don't break existing apps). Please send email to info@rowsandall.com with questions and/or suggestions. We @@ -84,7 +84,6 @@

  • Disadvantages

    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 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/panel_comments.html b/rowers/templates/panel_comments.html index 09d0d0f0..2d4fd917 100644 --- a/rowers/templates/panel_comments.html +++ b/rowers/templates/panel_comments.html @@ -11,12 +11,16 @@ Distance:{{ workout.distance }}m Duration:{{ workout.duration |durationprint:"%H:%M:%S.%f" }} - + + {% if workout.privacy != 'hidden' %} + Public link to this workout https://rowsandall.com/rowers/workout/{{ workout.id|encode }} - + + {% endif %} + Comments Comment ({{ aantalcomments }}) diff --git a/rowers/templates/rower_exportsettings.html b/rowers/templates/rower_exportsettings.html index b11ba412..56c82066 100644 --- a/rowers/templates/rower_exportsettings.html +++ b/rowers/templates/rower_exportsettings.html @@ -7,8 +7,10 @@ {% block main %}

    Import and Export Settings for {{ rower.user.first_name }} {{ rower.user.last_name }}

    +
    + {% csrf_token %}
    -

    Click on one of the icons below to connect to the service of your - choice or to renew the authorization.

    -

    connect with strava

    -

    connect with Concept2

    -

    connect with NK Logbook

    -

    connect with SportTracks

    -

    connect with Polar

    -

    connect with Polar

    - -

    connect with Garmin

    -

    connect with RP3

    -

    connect with Rojabo

    -{% if user.is_staff %} -

    iDoklad authorize

    -{% endif %} + {% endblock %} diff --git a/rowers/templates/rower_form.html b/rowers/templates/rower_form.html index 4912dd0e..1dd250ba 100644 --- a/rowers/templates/rower_form.html +++ b/rowers/templates/rower_form.html @@ -35,6 +35,7 @@
    {{ userform.as_table }} + {{ privateform.as_table }} {{ accountform.as_table }} diff --git a/rowers/templates/workout_form.html b/rowers/templates/workout_form.html index 98034e21..16c461fa 100644 --- a/rowers/templates/workout_form.html +++ b/rowers/templates/workout_form.html @@ -150,25 +150,28 @@ - + + {% if workout.privacy != 'hidden' %} + - {% for course in courses %} - - - - - {% endfor %} -
     Duration:{{ workout.duration |durationprint:"%H:%M:%S.%f" }}
    Source:{{ workout.workoutsource }}
    Public link to this workout: https://rowsandall.com/rowers/workout/{{ workout.id|encode }}/
    - Timed Course: - - {{ course }} -
    - -
  • + {% endif %} + {% for course in courses %} + + + Timed Course: + + + {{ course }} + + +{% endfor %} + +
  • +
  • {% if form.errors %}

    Please correct the error{{ form.errors|pluralize }} below. diff --git a/rowers/tests/test_api.py b/rowers/tests/test_api.py index 74dff25d..14f0429a 100644 --- a/rowers/tests/test_api.py +++ b/rowers/tests/test_api.py @@ -22,12 +22,454 @@ from rowers.opaque import encoder from rest_framework.test import APIRequestFactory, force_authenticate +UPLOAD_SERVICE_URL = '/rowers/workout/api/upload/' +UPLOAD_SERVICE_SECRET = "FoYezZWLSyfAVimumpHEeYsJjsNCerxV" + import json +# import BeautifulSoup +from bs4 import BeautifulSoup + from rowers.ownapistuff import * from rowers.views.apiviews import * from rowers.models import APIKey +from rowers.teams import add_member, add_coach +from rowers.views.analysisviews import histodata + +class TeamFactory(factory.DjangoModelFactory): + class Meta: + model = Team + + name = factory.LazyAttribute(lambda _: faker.word()) + notes = faker.text() + private = 'open' + viewing = 'allmembers' + +class StravaPrivacy(TestCase): + def setUp(self): + self.u = UserFactory() + self.u2 = UserFactory() + self.u3 = UserFactory() + + self.r = Rower.objects.create(user=self.u, + birthdate=faker.profile()['birthdate'], + gdproptin=True, ftpset=True,surveydone=True, + gdproptindate=timezone.now(), + rowerplan='coach',subscription_id=1) + + self.r.stravatoken = '12' + self.r.stravarefreshtoken = '123' + self.r.stravatokenexpirydate = arrow.get(datetime.datetime.now()-datetime.timedelta(days=1)).datetime + self.r.strava_owner_id = 4 + + self.r.save() + + self.c = Client() + + self.factory = RequestFactory() + self.password = faker.word() + self.u.set_password(self.password) + self.u.save() + self.factory = APIRequestFactory() + + self.r2 = Rower.objects.create(user=self.u2, + birthdate=faker.profile()['birthdate'], + gdproptin=True, ftpset=True,surveydone=True, + gdproptindate=timezone.now(), + rowerplan='coach',clubsize=3) + + self.r3 = Rower.objects.create(user=self.u3, + birthdate=faker.profile()['birthdate'], + gdproptin=True, ftpset=True,surveydone=True, + gdproptindate=timezone.now(), + rowerplan='basic') + + self.c = Client() + + self.password2 = faker.word() + self.u2.set_password(self.password2) + self.u2.save() + + self.password3 = faker.word() + self.u3.set_password(self.password3) + self.u3.save() + + self.team = TeamFactory(manager=self.u2) + + # all are team members + add_member(self.team.id, self.r) + add_member(self.team.id, self.r2) + add_member(self.team.id, self.r3) + + self.user_workouts = WorkoutFactory.create_batch(5, user=self.r) + for w in self.user_workouts: + if w.id <= 2: + w.workoutsource = 'strava' + w.privacy = 'hidden' + elif w.id == 3: # user can change privacy but cannot change workoutsource + w.workoutsource = 'strava' + w.privacy = 'visible' + else: + w.workoutsource = 'concept2' + w.privacy = 'visible' + w.team.add(self.team) + w.csvfilename = get_random_file(filename='rowers/tests/testdata/thyro.csv')['filename'] + w.save() + + # r2 coaches r + add_coach(self.r2, self.r) + + self.factory = APIRequestFactory() + + def tearDown(self): + for workout in self.user_workouts: + try: + os.remove(workout.csvfilename) + except (OSError, FileNotFoundError, IOError): + pass + + # Test if workout with workoutsource strava and privacy hidden can be seen by coach + def test_privacy_coach(self): + login = self.c.login(username=self.u2.username, password=self.password2) + self.assertTrue(login) + + w = self.user_workouts[0] + url = reverse('workout_view',kwargs={'id':encoder.encode_hex(w.id)}) + response = self.c.get(url) + self.assertEqual(response.status_code,403) + + # Same test as above but for 'workout_edit_view' + def test_privacy_coach_edit(self): + login = self.c.login(username=self.u2.username, password=self.password2) + self.assertTrue(login) + + w = self.user_workouts[0] + url = reverse('workout_edit_view',kwargs={'id':encoder.encode_hex(w.id)}) + response = self.c.get(url) + self.assertEqual(response.status_code,403) + + # Test if workout with workoutsource strava and privacy hidden can be seen by team member + def test_privacy_member(self): + login = self.c.login(username=self.u3.username, password=self.password3) + self.assertTrue(login) + + w = self.user_workouts[0] + url = reverse('workout_view',kwargs={'id':encoder.encode_hex(w.id)}) + response = self.c.get(url) + self.assertEqual(response.status_code,403) + + # Same test as above but for 'workout_edit_view' + def test_privacy_member_edit(self): + login = self.c.login(username=self.u3.username, password=self.password3) + self.assertTrue(login) + + w = self.user_workouts[0] + url = reverse('workout_edit_view',kwargs={'id':encoder.encode_hex(w.id)}) + response = self.c.get(url) + self.assertEqual(response.status_code,403) + + # same test as above but with user r and the response code should be 200 + def test_privacy_owner(self): + login = self.c.login(username=self.u.username, password=self.password) + self.assertTrue(login) + + w = self.user_workouts[0] + url = reverse('workout_view',kwargs={'id':encoder.encode_hex(w.id)}) + response = self.c.get(url) + self.assertEqual(response.status_code,200) + + # same test as above but for 'workout_edit_view' + def test_privacy_owner_edit(self): + login = self.c.login(username=self.u.username, password=self.password) + self.assertTrue(login) + + w = self.user_workouts[0] + url = reverse('workout_edit_view',kwargs={'id':encoder.encode_hex(w.id)}) + response = self.c.get(url) + self.assertEqual(response.status_code,200) + + + + # test if list_workouts returns all workouts for user r + def test_list_workouts(self): + login = self.c.login(username=self.u.username, password=self.password) + self.assertTrue(login) + + url = reverse('workouts_view') + response = self.c.get(url) + self.assertEqual(response.status_code,200) + + # the response.content is html, so we need to parse it + soup = BeautifulSoup(response.content, 'html.parser') + # the workouts look like ... and there should be 5 unique ids + # the id is a hex string + workouts = set([a['href'].split('/')[3] for a in soup.find_all('a') if a['href'].startswith('/rowers/workout/')]) + + # throw out "c2import", "nkimport", "stravaimport", "concept2import", "sporttracksimport" from the set + workouts = set([w for w in workouts if w not in [ + 'upload', 'addmanual', 'c2import', 'polarimport', 'rp3import', 'nkimport', 'stravaimport', 'concept2import', 'sporttracksimport', + 'intervalsimport']]) + + self.assertEqual(len(workouts),5) + + + # same test as above but list_workouts with team id = self.team.id + def test_list_workouts_team(self): + login = self.c.login(username=self.u.username, password=self.password) + self.assertTrue(login) + + url = reverse('workouts_view',kwargs={'teamid':self.team.id}) + response = self.c.get(url) + self.assertEqual(response.status_code,200) + + # the response.content is html, so we need to parse it + soup = BeautifulSoup(response.content, 'html.parser') + # the workouts look like ... and there should be 5 unique ids + # the id is a hex string + workouts = set([a['href'].split('/')[3] for a in soup.find_all('a') if a['href'].startswith('/rowers/workout/')]) + + # throw out "c2import", "nkimport", "stravaimport", "concept2import", "sporttracksimport" from the set + workouts = set([w for w in workouts if w not in [ + 'upload', 'addmanual', 'c2import', 'polarimport', 'rp3import', 'nkimport', 'stravaimport', 'concept2import', 'sporttracksimport', + 'intervalsimport']]) + + self.assertEqual(len(workouts),2) + + # same test as the previous one but with self.r2 and the number of workouts found should 0 + def test_list_workouts_team_coach(self): + login = self.c.login(username=self.u2.username, password=self.password2) + self.assertTrue(login) + + url = reverse('workouts_view',kwargs={'teamid':self.team.id}) + response = self.c.get(url) + self.assertEqual(response.status_code,200) + + # the response.content is html, so we need to parse it + soup = BeautifulSoup(response.content, 'html.parser') + # the workouts look like ... and there should be 5 unique ids + # the id is a hex string + workouts = set([a['href'].split('/')[3] for a in soup.find_all('a') if a['href'].startswith('/rowers/workout/')]) + + # throw out "c2import", "nkimport", "stravaimport", "concept2import", "sporttracksimport" from the set + workouts = set([w for w in workouts if w not in [ + 'upload', 'addmanual', 'c2import', 'polarimport', 'rp3import', 'nkimport', 'stravaimport', 'concept2import', 'sporttracksimport', + 'intervalsimport']]) + + self.assertEqual(len(workouts),2) + + # same test as above but with without the teamid kwarg but with a rowerid=self.r.id + def test_list_workouts_team_coach2(self): + login = self.c.login(username=self.u2.username, password=self.password2) + self.assertTrue(login) + + url = reverse('workouts_view',kwargs={'rowerid':self.r.id}) + response = self.c.get(url) + self.assertEqual(response.status_code,200) + + # the response.content is html, so we need to parse it + soup = BeautifulSoup(response.content, 'html.parser') + # the workouts look like ... and there should be 5 unique ids + # the id is a hex string + workouts = set([a['href'].split('/')[3] for a in soup.find_all('a') if a['href'].startswith('/rowers/workout/')]) + + # throw out "c2import", "nkimport", "stravaimport", "concept2import", "sporttracksimport" from the set + workouts = set([w for w in workouts if w not in [ + 'upload', 'addmanual', 'c2import', 'polarimport', 'rp3import', 'nkimport', 'stravaimport', 'concept2import', 'sporttracksimport', + 'intervalsimport']]) + + self.assertEqual(len(workouts),2) + + # same test as the previous one but with self.r3 and the number of workouts found should 0 + def test_list_workouts_team_member(self): + login = self.c.login(username=self.u3.username, password=self.password3) + self.assertTrue(login) + + url = reverse('workouts_view',kwargs={'teamid':self.team.id}) + response = self.c.get(url) + self.assertEqual(response.status_code,200) + + # the response.content is html, so we need to parse it + soup = BeautifulSoup(response.content, 'html.parser') + # the workouts look like ... and there should be 5 unique ids + # the id is a hex string + workouts = set([a['href'].split('/')[3] for a in soup.find_all('a') if a['href'].startswith('/rowers/workout/')]) + + # throw out "c2import", "nkimport", "stravaimport", "concept2import", "sporttracksimport" from the set + workouts = set([w for w in workouts if w not in [ + 'upload', 'addmanual', 'c2import', 'polarimport', 'rp3import', 'nkimport', 'stravaimport', 'concept2import', 'sporttracksimport', + 'intervalsimport']]) + + self.assertEqual(len(workouts),2) + + # now test strava import and test if the created workout has workoutsource strava and privacy hidden + @patch('rowers.utils.requests.get', side_effect=mocked_requests) + @patch('rowers.integrations.strava.requests.post', side_effect=mocked_requests) + @patch('rowers.dataprep.read_data') + def test_stravaimport(self, mock_get, mock_post, mocked_read_data): + login = self.c.login(username=self.u.username, password=self.password) + self.assertTrue(login) + + # remove all self.workouts + Workout.objects.filter(user=self.r).delete() + + # create a workout using dataprep.new_workout_from_file with workoutsource = strava + result = get_random_file(filename='rowers/tests/testdata/thyro.csv') + workout_id, message, filename = dataprep.new_workout_from_file(self.r, result['filename'], + workoutsource='strava', makeprivate=True) + + # check if the workout was created + ws = Workout.objects.filter(user=self.r) + self.assertEqual(len(ws),1) + w = ws[0] + self.assertEqual(w.workoutsource,'strava') + self.assertEqual(w.privacy,'hidden') + + # same as test above but makeprivate = False + @patch('rowers.utils.requests.get', side_effect=mocked_requests) + @patch('rowers.integrations.strava.requests.post', side_effect=mocked_requests) + @patch('rowers.dataprep.read_data') + def test_stravaimport_public(self, mock_get, mock_post, mocked_read_data): + login = self.c.login(username=self.u.username, password=self.password) + self.assertTrue(login) + + # remove all self.workouts + Workout.objects.filter(user=self.r).delete() + + # create a workout using dataprep.new_workout_from_file with workoutsource = strava + result = get_random_file(filename='rowers/tests/testdata/thyro.csv') + workout_id, message, filename = dataprep.new_workout_from_file(self.r, result['filename'], + workoutsource='strava', makeprivate=False) + + # check if the workout was created + ws = Workout.objects.filter(user=self.r) + self.assertEqual(len(ws),1) + w = ws[0] + self.assertEqual(w.workoutsource,'strava') + self.assertEqual(w.privacy,'hidden') + + + # test ownapi with stravaid = '122' + def test_ownapi(self): + # remove all self.workouts + Workout.objects.filter(user=self.r).delete() + + result = get_random_file(filename='rowers/tests/testdata/thyro.csv') + uploadoptions = { + 'workouttype': 'water', + 'boattype': '1x', + 'notes': 'A test file upload', + 'stravaid': '122', + 'secret': UPLOAD_SERVICE_SECRET, + 'user': self.u.id, + 'file': result['filename'], + } + url = reverse('workout_upload_api') + response = self.c.post(url, uploadoptions) + self.assertEqual(response.status_code,200) + + # check if the workout was created + ws = Workout.objects.filter(user=self.r) + self.assertEqual(len(ws),1) + w = ws[0] + self.assertEqual(w.workoutsource,'strava') + self.assertEqual(w.privacy,'hidden') + + + # test some analysis, should only use the workouts with workoutsource != strava + #@patch('rowers.dataprep.read_data', side_effect=mocked_read_data) + #def test_workouts_analysis(self, mocked_read_data): + def test_workouts_analysis(self): + login = self.c.login(username=self.u.username, password=self.password) + self.assertTrue(login) + + url = '/rowers/history/' + response = self.c.get(url) + self.assertEqual(response.status_code,200) + + url = '/rowers/history/data/' + response = self.c.get(url) + self.assertEqual(response.status_code,200) + # response.json() has a key "script" with a javascript script + # check if this is correct + self.assertTrue('script' in response.json()) + + # now check histogram + startdate = (self.user_workouts[0].startdatetime-datetime.timedelta(days=3)).date() + enddate = (self.user_workouts[0].startdatetime+datetime.timedelta(days=3)).date() + + # make sure the dates are not naive + try: + startdate = pytz.utc.localize(startdate) + except (ValueError, AttributeError): + pass + try: + enddate = pytz.utc.localize(enddate) + except (ValueError, AttributeError): + pass + + form_data = { + 'function':'histo', + 'xparam':'hr', + 'plotfield':'spm', + 'yparam':'pace', + 'groupby':'spm', + 'palette':'monochrome_blue', + 'xaxis':'time', + 'yaxis1':'power', + 'yaxis2':'hr', + 'startdate':startdate, + 'enddate':enddate, + 'plottype':'scatter', + 'spmmin':15, + 'spmmax':55, + 'workmin':0, + 'workmax':1500, + 'includereststrokes':False, + 'modality':'all', + 'waterboattype':['1x','2x','4x'], + 'userid':self.u.id, + 'workouts':[w.id for w in Workout.objects.filter(user=self.r)], + } + + form = AnalysisChoiceForm(form_data) + optionsform = AnalysisOptionsForm(form_data) + dateform = DateRangeForm(form_data) + + result = form.is_valid() + if not result: + print(form.errors) + + self.assertTrue(form.is_valid()) + self.assertTrue(optionsform.is_valid()) + self.assertTrue(dateform.is_valid()) + + response = self.c.post('/rowers/user-analysis-select/',form_data) + + self.assertEqual(response.status_code,200) + + # count number of workouts by counting the number of occurences of '