diff --git a/rowers/templates/developers.html b/rowers/templates/developers.html index b6529e1a..3e5ed958 100644 --- a/rowers/templates/developers.html +++ b/rowers/templates/developers.html @@ -658,6 +658,90 @@ fails a test, its values are silently replaced by zeros.

+ + Stroke Data (v3) + /rowers/api/v3/workouts/ + + POST + +
{
+    "totalDistance": 100,
+    "elapsedTime": 29000,
+    "title": "Test Workout (GO)",
+    "startdatetime": "2023-01-16 17:54:35.588838+00:00",
+    "workouttype": "water",
+    "boattype": "1x",
+    "notes": "some\nnotes",
+    "strokes": {
+	"data": [
+	{
+          "time": 3200.0000476837,
+          "pace": 155068.4885951763,
+          "hr": 85.7857142857,
+          "power": 84.6531131591,
+          "distance": 23,
+          "spm": 16.380952381
+	},
+	{
+	  "time": 6700.0000476837,
+          "pace" : 144402.6407586741,
+          "hr": 91.2142857143,
+          "power": 117.458827834,
+          "distance": 36,
+          "spm": 21.1666666667
+	},
+	{
+          "time": 10099.9999046326,
+          "pace": 138830.8712654931,
+          "hr": 95.7142857143,
+          "power": 141.31057207,
+          "distance": 48,
+          "spm": 19.8095238095
+	}
+	]
+     }
+}
+              
+ With v3, you can post stroke data and workout metadata in one JSON object. +
+

For v3, mandatory data fields are:

+

+

+

+

Optional data fiels are:

+

+

+

+

Also for this API version, 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.

+ +

diff --git a/rowers/tests/test_api.py b/rowers/tests/test_api.py index f1a6f73b..32fc2af6 100644 --- a/rowers/tests/test_api.py +++ b/rowers/tests/test_api.py @@ -180,3 +180,75 @@ class OwnApi(TestCase): response = strokedatajson_v2(request,id=w.id) self.assertEqual(response.status_code,200) + + def test_strokedataform_v3(self): + login = self.c.login(username=self.u.username, password=self.password) + self.assertTrue(login) + + w = self.user_workouts[1] + + url = reverse('strokedatajson_v3') + + request = self.factory.get(url) + request.user = self.u + force_authenticate(request, user=self.u) + response = strokedatajson_v3(request) + self.assertEqual(response.status_code,405) + + + strokedata = { + "data": [ + { + "time": 3200.0000476837, + "pace": 155068.4885951763, + "hr": 85.7857142857, + "power": 84.6531131591, + "distance": 23, + "spm": 16.380952381 + }, + { + "time": 6700.0000476837, + "pace" : 144402.6407586741, + "hr": 91.2142857143, + "power": 117.458827834, + "distance": 36, + "spm": 21.1666666667 + }, + { + "time": 10099.9999046326, + "pace": 138830.8712654931, + "hr": 95.7142857143, + "power": 141.31057207, + "distance": 48, + "spm": 19.8095238095 + } + ] + } + + form_data = { + "totalDistance": 100, + "elapsedTime": 29000, + "title": "Test Workout (GO)", + "startdatetime": "2023-01-16 17:54:35.588838+00:00", + "workouttype": "water", + "boattype": "1x", + "notes": "some\nnotes", + "strokes": strokedata, + } + + + url = reverse('strokedatajson_v3') + + request = self.factory.post(url,form_data,format='json') + request.user = self.u + request.data = json.dumps(form_data) + + force_authenticate(request, user=self.u) + with patch('rowers.dataprep.getrowdata_db') as mock_getrowdata: + mock_getrowdata.return_value = (pd.DataFrame(),None) + response = strokedatajson_v3(request) + + self.assertEqual(response.status_code,200) + response = json.loads(response.content) + x = response['workout id'] + diff --git a/rowers/urls.py b/rowers/urls.py index 9c59e802..c9760f9a 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -247,6 +247,8 @@ urlpatterns = [ 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'^api/v3/workouts/$', views.strokedatajson_v3, + name='strokedatajson_v3'), re_path(r'^500v/$', views.error500_view, name='error500_view'), re_path(r'^500q/$', views.servererror_view, name='servererror_view'), path('502/', TemplateView.as_view(template_name='502.html'), name='502'), diff --git a/rowers/views/apiviews.py b/rowers/views/apiviews.py index 06745200..88efe374 100644 --- a/rowers/views/apiviews.py +++ b/rowers/views/apiviews.py @@ -3,6 +3,8 @@ from rowers.tasks import handle_calctrimp from rowers.opaque import encoder import arrow +import pendulum +from rowsandall_app.settings import UPLOAD_SERVICE_SECRET, UPLOAD_SERVICE_URL # Stroke data form to test API upload @@ -37,6 +39,138 @@ def strokedataform(request, id=0): 'workout': w, }) # pragma: no cover +def api_get_dataframe(startdatetime, df): + try: + time = df['time']/1.e3 + except KeyError: # pragma: no cover + try: + time = df['t']/10. + except KeyError: + return 400, "Missing time", pd.DataFrame() + + try: + spm = df['spm'] + except KeyError: # pragma: no cover + return 400, "Missing spm", pd.DataFrame() + try: + distance = df['distance'] + except KeyError: # pragma: no cover + try: + distance = df['d']/10. + except KeyError: + return 400, "Missing distance", pd.DataFrame() + + try: + pace = df['pace']/1.e3 + except KeyError: # pragma: no cover + try: + pace = df['p']/10. + except KeyError: + return 400, "Missing pace", pd.DataFrame() + + + try: + power = df['power'] + except KeyError: # pragma: no cover + power = 0*time + try: + drivelength = df['drivelength'] + except KeyError: + drivelength = 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: # pragma: no cover + hr = 0*df['time'] + + try: + latitude = df['latitude'] + except KeyError: + latitude = 0*df['time'] + + try: + longitude = df['longitude'] + except KeyError: + longitude = 0*df['time'] + + starttime = totimestamp(startdatetime)+time[0] + unixtime = starttime+time + + dologging('apilog.log',"(strokedatajson_v2/3 POST - data parsed)") + + 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, + ' latitude': latitude, + ' longitude': longitude, + }) + + return 200, "", data + @login_required() def strokedataform_v2(request, id=0): @@ -69,10 +203,127 @@ def strokedataform_v2(request, id=0): }) # pragma: no cover +@csrf_exempt +@login_required() +@api_view(["POST"]) +@permission_classes([IsAuthenticated]) +def strokedatajson_v3(request): + """ + POST: Add Stroke data to workout + GET: Get stroke data of workout + This v2 API works on stroke based data dict: + { + "totalDistance": 100, + "elapsedTime": 592, + "title": "Test Workout (GO)", + "startdatetime": "2023-01-16 17:54:35.588838+00:00", + "workouttype": "water", + "boattype": "1x", + "notes": "some\nnotes", + "strokes": {"data": [ + {"distance":5, "power": 112, "hr": 132, "pace": 145800, "spm": 11, "time": 0, "latitude":52.2264097,"longitude":6.8493638}, + {"distance":12, "power": 221, "hr": 131, "pace": 116400, "spm": 41, "time": 2200, "latitude":52.2263474,"longitude":6.8495814}, + {"distance":19, "power": 511, "hr": 131, "pace": 88100, "spm": 56, "time": 4599, "latitude":52.2262715,"longitude":6.8496975}, + {"distance":27, "power": 673, "hr": 132, "pace": 80400, "spm": 59, "time": 7000, "latitude":52.2262003,"longitude":6.8498095}, + {"distance":35, "power": 744, "hr": 133, "pace": 77700, "spm": 55, "time": 9599, "latitude":52.2261312, "longitude":6.8499267}, + {"distance":43, "power": 754, "hr": 136, "pace": 77400, "spm": 48, "time": 12000, "latitude":52.2260576,"longitude":6.8500366}, + {"distance":51, "power": 754, "hr": 139, "pace": 77400, "spm": 48, "time": 14400, "latitude":52.2259872,"longitude":6.8501561}, + {"distance":59, "power": 749, "hr": 142, "pace": 77600, "spm": 48, "time": 16799, "latitude":52.2259154,"longitude":6.8502769}, + {"distance":67, "power": 729, "hr": 145, "pace": 78300, "spm": 48, "time": 19400, "latitude":52.2257749,"longitude":6.8503918}, + {"distance":75, "power": 729, "hr": 147, "pace": 78300, "spm": 48, "time": 21799, "latitude":52.2259154,"longitude":6.8502769}, + {"distance":82, "power": 726, "hr": 150, "pace": 78400, "spm": 48, "time": 24200, "latitude":52.2259872,"longitude":6.8501561}, + {"distance":90, "power": 709, "hr": 152, "pace": 79000, "spm": 48, "time": 26599, "latitude":52.2260576,"longitude":6.8500366}, + {"distance":100, "power": 707, "hr": 153, "pace": 79100, "spm": 49, "time": 29000, "latitude":52.2261312,"longitude":6.8499267}]} + +} + + """ + + if request.method != 'POST': + return HttpResponseNotAllowed("Method not supported") # pragma: no cover + + dologging('apilog.log', request.user.username+" (strokedatajson_v3 POST)") + + title = request.data.get('title','') + try: + elapsedTime = request.data['elapsedTime'] + except KeyError: + return HttpResponse("Missing Elapsed Time", status=400) + try: + totalDistance = request.data['totalDistance'] + except KeyError: + return HttpResponse("Missing Total Distance", status=400) + timeZone = request.data.get('timezone','UTC') + workouttype = request.data.get('workouttype','rower') + boattype = request.data.get('boattype','1x') + notes = request.data.get('notes','') + rpe = request.data.get('rpe',0) + startdatetime = request.data.get('startdatetime',"%s" % timezone.now()) + + startdatetime = pendulum.parse(startdatetime) + + df = pd.DataFrame() + try: + strokes = request.data['strokes'] + except KeyError: + return HttpResponse("No Stroke Data in JSON", status=400) + + try: + df = pd.DataFrame(strokes['data']) + except KeyError: + try: + df = pd.DataFrame(request.data['strokedata']) + except: + return HttpResponse("No JSON Object could be decoded", status=400) + + df.index = df.index.astype(int) + df.sort_index(inplace=True) + + status, comment, data = api_get_dataframe(startdatetime, df) + + if status != 200: + return HttpResponse(comment, status=status) + + + csvfilename = 'media/{code}.csv.gz'.format(code=uuid4().hex[:16]) + _ = data.to_csv(csvfilename, index_label='index', compression='gzip') + + uploadoptions = { + 'secret': UPLOAD_SERVICE_SECRET, + 'user': request.user.id, + 'file': csvfilename, + 'title': title, + 'workouttype': workouttype, + 'boattype': boattype, + 'elapsedTime': elapsedTime/1000., # in seconds + 'totalDistance': totalDistance, + 'rpe': rpe, + 'notes': notes, + 'timezone': timeZone, + } + session = requests.session() + newHeaders = {'Content-type': 'application/json', 'Accept': 'text/plain'} + session.headers.update(newHeaders) + + response = session.post(UPLOAD_SERVICE_URL, json=uploadoptions) + + if response.status_code != 200: + return HttpResponse(response.text, response.status_code) + + try: + workoutid = response.json()['id'] + except KeyError: + workoutid = 1 + + return JsonResponse( + {"workout public id": encoder.encode_hex(workoutid), + "workout id": workoutid, + "status": "success", + }) + + # Process the POSTed stroke data according to the API definition # Return the GET stroke data according to the API definition - - @csrf_exempt @login_required() @api_view(["GET", "POST"]) @@ -161,135 +412,10 @@ def strokedatajson_v2(request, id): df.index = df.index.astype(int) df.sort_index(inplace=True) - try: - time = df['time']/1.e3 - except KeyError: # pragma: no cover - try: - time = df['t']/10. - except KeyError: - return HttpResponse("Missing time", status=400) - - try: - spm = df['spm'] - except KeyError: # pragma: no cover - return HttpResponse("Missing spm", status=400) - - try: - distance = df['distance'] - except KeyError: # pragma: no cover - try: - distance = df['d']/10. - except KeyError: - return HttpResponse("Missing distance", status=400) - - try: - pace = df['pace']/1.e3 - except KeyError: # pragma: no cover - try: - pace = df['p']/10. - except KeyError: - return HttpResponse("Missing pace", status=400) - - try: - power = df['power'] - except KeyError: # pragma: no cover - power = 0*time - try: - drivelength = df['drivelength'] - except KeyError: - drivelength = 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: # pragma: no cover - hr = 0*df['time'] - - try: - latitude = df['latitude'] - except KeyError: - latitude = 0*df['time'] - - try: - longitude = df['longitude'] - except KeyError: - longitude = 0*df['time'] - - starttime = totimestamp(row.startdatetime)+time[0] - unixtime = starttime+time - - dologging('apilog.log',"(strokedatajson_v2 POST - data parsed)") - - 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, - ' latitude': latitude, - ' longitude': longitude, - }) - + status, comment, data = api_get_dataframe(row.startdatetime, df) + if status != 200: + return HttpResponse(comment, status=status) + r = getrower(request.user) timestr = row.startdatetime.strftime("%Y%m%d-%H%M%S")