diff --git a/rowers/templates/developers.html b/rowers/templates/developers.html index ce4f8d05..51dd5ce6 100644 --- a/rowers/templates/developers.html +++ b/rowers/templates/developers.html @@ -10,91 +10,91 @@

On this page, a work in progress, I will collect useful information for developers of rowing data apps and hardware.

- +

I presume you have an app (smartphone app, dedicated hardware, web site) where your users (customers) generate, collect or store their rowing related workout data. You can now offer your users easy ways to get their data on this site.

- + -
  • +
  • There are three ways to allow your users to get data to Rowsandall.com.

    File based export from your app

    - +

    Enable export of TCX, FIT or CSV formatted files from your app. The users upload the file to Rowsandall.com.

    - -

    Email from your app

    - +

    Similar as above, generate TCX, FIT or CSV formatted files and email them to workouts@rowsandall.com directly from your app. The From: field should be the email address of the registered user.

    - -

    Using the REST API

    - +

    We are building 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 suggestions (provided they don't break existing apps). Please send email to info@rowsandall.com with questions and/or suggestions. We will get back to you as soon as possible.

    -

    +
  • -
  • +
  • Quick Links

    Accepted file formats

    @@ -103,20 +103,20 @@

    However, some rowing related parameters are not supported by TCX and FIT. Therefore, we are supporting the CSV format that is documented in the following link.

    - +

    Using this standard will guarantee that your user's data are accepted without complaints.

    API related documentation

    - +

    Registering an app

    We have disabled the self service app link for security reasons. We will replace it with a secure self service app link soon. If you need to register an app, please send email to info@rowsandall.com

    - +

    Authentication

    Standard Oauth2 authentication. @@ -128,14 +128,15 @@

    The redirect URI for user authentication has to be https. Developers of iOS or Android apps should contact me directly if this doesn't work for them. I can add exceptions.

    - +

    +

    API documentation

    @@ -146,12 +147,14 @@

    The workout summary data and the stroke data are obtained and sent separately.

    +

    +

    + +

    POST stroke data - API

    You can only post stroke data to an existing workout with workout number {id}. If the workout already has stroke data, you @@ -159,9 +162,13 @@ future to enable updating stroke data. Stroke data for workout {id} are posted to:

    +

    API v1

    + +

    +

    The payload is application/json data and looks as follows:

    @@ -176,14 +183,48 @@ }

    -

    Mandatory data fields are:

    +

    API v2

    + +

    +

    +

    + +

    The payload is application/json data and looks as follows:

    + +

    +    {'data':
    +    [
    +    {"distance":5, "power": 112, "hr": 132, "pace": 145800, "spm": 11, "time": 0},
    +    {"distance":12, "power": 221, "hr": 131, "pace": 116400, "spm": 41, "time": 2200},
    +    {"distance":19, "power": 511, "hr": 131, "pace": 88100, "spm": 56, "time": 4599},
    +    {"distance":27, "power": 673, "hr": 132, "pace": 80400, "spm": 59, "time": 7000},
    +    {"distance":35, "power": 744, "hr": 133, "pace": 77700, "spm": 55, "time": 9599},
    +    {"distance":43, "power": 754, "hr": 136, "pace": 77400, "spm": 48, "time": 12000},
    +    {"distance":51, "power": 754, "hr": 139, "pace": 77400, "spm": 48, "time": 14400},
    +    {"distance":59, "power": 749, "hr": 142, "pace": 77600, "spm": 48, "time": 16799},
    +    {"distance":67, "power": 729, "hr": 145, "pace": 78300, "spm": 48, "time": 19400},
    +    {"distance":75, "power": 729, "hr": 147, "pace": 78300, "spm": 48, "time": 21799},
    +    {"distance":82, "power": 726, "hr": 150, "pace": 78400, "spm": 48, "time": 24200},
    +    {"distance":90, "power": 709, "hr": 152, "pace": 79000, "spm": 48, "time": 26599},
    +    {"distance":100, "power": 707, "hr": 153, "pace": 79100, "spm": 49, "time": 29000},
    +    ]
    +}
    +    

    + + +

    For both v1 and v2, mandatory data fields are:

    +

    +

    Optional data fiels are:

    +

    - -

    Consistency checks will be done and the stroke data will be +

    +

    For both API versions, consistency checks will be done and the stroke data will be refused if the mandatory data fields don't pass the checks. All mandatory data fields must have the same number of records. If an optional data field fails a test, its values are silently replaced by zeros.

    - +
  • @@ -216,4 +257,3 @@ {% block sidebar %} {% include 'menu_help.html' %} {% endblock %} - diff --git a/rowers/templates/strokedata_form_v2.html b/rowers/templates/strokedata_form_v2.html new file mode 100644 index 00000000..38ceee2a --- /dev/null +++ b/rowers/templates/strokedata_form_v2.html @@ -0,0 +1,23 @@ +{% extends "newbase.html" %} + +{% block main %} +{% if form.errors %} +

    + Please correct the error{{ form.errors|pluralize }} below. +

    +{% endif %} +

    Stroke Data for workout

    + +
    + + {{ form.as_table }} +
    + {% csrf_token %} + +
    + +{% endblock %} + +{% block sidebar %} +{% include 'menu_workout.html' %} +{% endblock %} diff --git a/rowers/urls.py b/rowers/urls.py index c815b68c..0b3635cb 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -210,6 +210,7 @@ urlpatterns = [ re_path(r'^api-docs/$', views.schema_view,name='schema_view'), re_path(r'^api-auth/', include('rest_framework.urls', namespace='rest_framework')), re_path(r'^api/workouts/(?P\b[0-9A-Fa-f]+\b)/strokedata/$',views.strokedatajson,name='strokedatajson'), + re_path(r'^api/v2/workouts/(?P\b[0-9A-Fa-f]+\b)/strokedata/$',views.strokedatajson_v2,name='strokedatajson_v2'), re_path(r'^500v/$',views.error500_view,name='error500_view'), path('502/', TemplateView.as_view(template_name='502.html'),name='502'), path('500/', TemplateView.as_view(template_name='500.html'),name='500'), @@ -738,6 +739,7 @@ urlpatterns = [ views.TrainingTargetUpdate.as_view()), name='trainingtarget_update_view'), re_path(r'^workout/(?P\b[0-9A-Fa-f]+\b)/test\_strokedata/$',views.strokedataform), + re_path(r'^workout/(?P\b[0-9A-Fa-f]+\b)/v2/test\_strokedata/$',views.strokedataform_v2), re_path(r'^sessions/teamcreate/user/(?P\d+)/$',views.plannedsession_teamcreate_view, name='plannedsession_teamcreate_view'), re_path(r'^sessions/teamcreate/team/(?P\d+)/user/(?P\d+)/$', diff --git a/rowers/views/apiviews.py b/rowers/views/apiviews.py index baf49a13..c0290179 100644 --- a/rowers/views/apiviews.py +++ b/rowers/views/apiviews.py @@ -40,10 +40,256 @@ def strokedataform(request,id=0): 'workout':w, }) +@login_required() +def strokedataform_v2(request,id=0): + + try: + id=int(id) + except ValueError: + id = 0 + + try: + w = Workout.objects.get(id=id) + except Workout.DoesNotExist: + raise Http404("Workout doesn't exist") + + if request.method == 'GET': + form = StrokeDataForm() + return render(request, 'strokedata_form_v2.html', + { + 'form':form, + 'teams':get_my_teams(request.user), + 'id':id, + 'workout':w, + }) + elif request.method == 'POST': + form = StrokeDataForm() + + return render(request, 'strokedata_form_v2.html', + { + 'form':form, + 'teams':get_my_teams(request.user), + 'id':id, + 'workout':w, + }) + + # Process the POSTed stroke data according to the API definition # Return the GET stroke data according to the API definition from rest_framework_swagger.renderers import OpenAPIRenderer, SwaggerUIRenderer +@csrf_exempt +@login_required() +@api_view(["GET","POST"]) +def strokedatajson_v2(request,id): + """ + POST: Add Stroke data to workout + GET: Get stroke data of workout + This v2 API works on stroke based data dict: + {"data": [{"hr": 110, "p": 3600, "spm": 53, "d": 6, "t": 12}, {"hr": 111, "p": 3600, "spm": 53, "d": 6, "t": 12}, {"hr": 111, "p": 3600, "spm": 64, "d": 6, "t": 22}, {"hr": 110, "p": 3600, "spm": 16, "d": 14, "t": 55}, {"hr": 110, "p": 3600, "spm": 16, "d": 14, "t": 82}, {"hr": 107, "p": 3600, "spm": 12, "d": 22, "t": 109}, {"hr": 107, "p": 3600, "spm": 12, "d": 22, "t": 133}, {"hr": 108, "p": 3600, "spm": 12, "d": 32, "t": 157}, {"hr": 108, "p": 3577, "spm": 12, "d": 32, "t": 157}, {"hr": 108, "p": 3411, "spm": 12, "d": 32, "t": 157}, {"hr": 108, "p": 2649, "spm": 12, "d": 32, "t": 157}, {"hr": 108, "p": 3099, "spm": 12, "d": 32, "t": 157}, {"hr": 108, "p": 3600, "spm": 12, "d": 32, "t": 157}, {"hr": 100, "p": 3600, "spm": 44, "d": 115, "t": 292}, {"hr": 99, "p": 3600, "spm": 27, "d": 129, "t": 305}, {"hr": 97, "p": 3600, "spm": 34, "d": 161, "t": 330}, {"hr": 96, "p": 3600, "spm": 25, "d": 177, "t": 344}, {"hr": 96, "p": 3494, "spm": 43, "d": 196, "t": 357}, {"hr": 98, "p": 2927, "spm": 26, "d": 235, "t": 377}, {"hr": 102, "p": 2718, "spm": 27, "d": 380, "t": 455}, {"hr": 102, "p": 2753, "spm": 9, "d": 398, "t": 472}, {"hr": 102, "p": 2864, "spm": 61, "d": 406, "t": 477}, {"hr": 101, "p": 2780, "spm": 15, "d": 484, "t": 515}, {"hr": 101, "p": 2365, "spm": 16, "d": 583, "t": 554}, {"hr": 103, "p": 1965, "spm": 16, "d": 681, "t": 592}, {"hr": 104, " + """ + + row = get_object_or_404(Workout,pk=id) + if row.user != request.user.rower: + raise PermissionDenied("You have no access to this workout") + + try: + id = int(id) + except ValueError: + return HttpResponse("Not a valid workout number",stauts=400) + + if request.method == 'GET': + columns = ['spm','time','hr','pace','power','distance'] + datadf = dataprep.getsmallrowdata_db(columns,ids=[id]) + with open('media/apilog.log','a') as logfile: + logfile.write(str(timezone.now())+": ") + logfile.write(request.user.username+"(GET) \n") + + data = datadf.to_json(orient='records') + + return JSONResponse(data) + + if request.method == 'POST': + checkdata, r = dataprep.getrowdata_db(id=row.id) + if not checkdata.empty: + return HttpResponse("Duplicate Error",400) + + df = pd.DataFrame() + + try: + df = pd.read_json(request.POST['data'],orient='split') + except KeyError: + try: + df = pd.read_json(request.POST['strokedata'],orient='split') + except: + return HttpResponse("No JSON object could be decoded",400) + + df.index = df.index.astype(int) + df.sort_index(inplace=True) + + #time, pace, distance,spm + try: + time = df['time']/1.e3 + except KeyError: + try: + time = df['t']/10. + except KeyError: + return HttpResponse("Missing time",400) + + try: + spm = df['spm'] + except KeyError: + return HttpResponse("Missing spm",400) + + try: + distance = df['distance'] + except KeyError: + try: + distance = df['d']/10. + except KeyError: + return HttpResponse("Missing distance",400) + + try: + pace = df['pace']/1.e3 + except KeyError: + try: + pace = df['p']/10. + except KeyError: + return HttpResponse("Missing pace",400) + + + + try: + power = df['power'] + except KeyError: + power = 0*time + try: + drivelength = df['drivelength'] + except KeyError: + drivelength = 0*time + try: + drivespeed = df['drivespeed'] + except KeyError: + drivespeed = 0*time + try: + dragfactor = df['dragfactor'] + except KeyError: + dragfactor = 0*time + try: + drivetime = df['drivetime'] + except KeyError: + drivetime = 0*time + try: + strokerecoverytime = df['strokerecoverytime'] + except KeyError: + strokerecoverytime = 0*time + try: + averagedriveforce = df['averagedriveforce'] + except KeyError: + averagedriveforce = 0*time + try: + peakdriveforce = df['peakdriveforce'] + except KeyError: + peakdriveforce = 0*time + try: + wash = df['wash'] + except KeyError: + wash = 0*time + try: + catch = df['catch'] + except KeyError: + catch = 0*time + try: + finish = df['finish'] + except KeyError: + finish = 0*time + try: + peakforceangle = df['peakforceangle'] + except KeyError: + peakforceangle = 0*time + try: + driveenergy = df['driveenergy'] + except KeyError: + driveenergy = 60.*power/spm + try: + slip = df['slip'] + except KeyError: + slip = 0*time + try: + lapidx = df['lapidx'] + except KeyError: + lapidx = 0*time + try: + hr = df['hr'] + except KeyError: + hr = 0*df['time'] + + + + starttime = totimestamp(row.startdatetime)+time[0] + unixtime = starttime+time + + with open('media/apilog.log','a') as logfile: + logfile.write(str(starttime)+": ") + logfile.write(request.user.username+"(POST) \r\n") + + + data = pd.DataFrame({'TimeStamp (sec)':unixtime, + ' Horizontal (meters)': distance, + ' Cadence (stokes/min)':spm, + ' HRCur (bpm)':hr, + ' DragFactor':dragfactor, + ' Stroke500mPace (sec/500m)':pace, + ' Power (watts)':power, + ' DriveLength (meters)':drivelength, + ' DriveTime (ms)':drivetime, + ' StrokeRecoveryTime (ms)':strokerecoverytime, + ' AverageDriveForce (lbs)':averagedriveforce, + ' PeakDriveForce (lbs)':peakdriveforce, + ' lapIdx':lapidx, + ' ElapsedTime (sec)':time, + 'catch':catch, + 'slip':slip, + 'finish':finish, + 'wash':wash, + 'driveenergy':driveenergy, + 'peakforceangle':peakforceangle, + }) + + r = getrower(request.user) + + timestr = row.startdatetime.strftime("%Y%m%d-%H%M%S") + csvfilename ='media/Import_'+timestr+'.csv' + + res = data.to_csv(csvfilename+'.gz',index_label='index', + compression='gzip') + row.csvfilename = csvfilename + row.save() + + powerperc = 100*np.array([r.pw_ut2, + r.pw_ut1, + r.pw_at, + r.pw_tr,r.pw_an])/r.ftp + + ftp = float(r.ftp) + if row.workouttype in mytypes.otwtypes: + ftp = ftp*(100.-r.otwslack)/100. + + rr = rrower(hrmax=r.max,hrut2=r.ut2, + hrut1=r.ut1,hrat=r.at, + hrtr=r.tr,hran=r.an,ftp=ftp, + powerperc=powerperc,powerzones=r.powerzones) + rowdata = rdata(row.csvfilename,rower=rr).df + + datadf = dataprep.dataprep(rowdata,id=row.id,bands=True,barchart=True,otwpower=True,empower=True) + + + return(HttpResponse(encoder.encode_hex(row.id),status=201)) + + return HttpResponseNotAllowed("Method not supported") + + + @csrf_exempt @login_required() @api_view(['GET','POST']) @@ -182,7 +428,7 @@ def strokedatajson(request,id): # mangling # - return HttpResponse(row.id,status=201) + return HttpResponse(encoder.encode_hex(row.id),status=201) #Method not supported return HttpResponseNotAllowed("Method not supported")