Private
Public Access
1
0

Merge branch 'release/v22.3.0'

This commit is contained in:
2024-12-06 10:37:28 +01:00
19 changed files with 826 additions and 401 deletions

15
rowers/authentication.py Normal file
View File

@@ -0,0 +1,15 @@
from rest_framework.authentication import BaseAuthentication
from rest_framework.exceptions import AuthenticationFailed
from rowers.models import APIKey
class APIKeyAuthentication(BaseAuthentication):
def authenticate(self, request):
api_key = request.META.get('HTTP_AUTHORIZATION')
if not api_key:
return None
try:
api_key = APIKey.objects.get(key=api_key, is_active=True)
except APIKey.DoesNotExist:
raise AuthenticationFailed('Invalid API key')
return (api_key.user, None)

View File

@@ -176,6 +176,7 @@ columndict = {
'slip': 'slip', 'slip': 'slip',
'workoutstate': ' WorkoutState', 'workoutstate': ' WorkoutState',
'cumdist': 'cum_dist', 'cumdist': 'cum_dist',
'check_factor': 'check_factor',
} }
@@ -1599,6 +1600,10 @@ def read_data(columns, ids=[], doclean=True, workstrokesonly=True, debug=False,
try: try:
datadf = pl.concat(data).select(columns) datadf = pl.concat(data).select(columns)
except ColumnNotFoundError:
datadf = pl.concat(data)
existing_columns = [col for col in columns if col in datadf.columns]
datadf = datadf.select(existing_columns)
except (ShapeError, SchemaError): except (ShapeError, SchemaError):
data = [ data = [
df.select(columns) df.select(columns)
@@ -1620,6 +1625,7 @@ def read_data(columns, ids=[], doclean=True, workstrokesonly=True, debug=False,
else: else:
intcolumns.append(c) intcolumns.append(c)
try:
data = [ data = [
df.with_columns( df.with_columns(
cs.float().cast(pl.Float64) cs.float().cast(pl.Float64)
@@ -1634,6 +1640,8 @@ def read_data(columns, ids=[], doclean=True, workstrokesonly=True, debug=False,
) )
for df in data for df in data
] ]
except ComputeError:
pass
try: try:
datadf = pl.concat(data) datadf = pl.concat(data)
@@ -2299,6 +2307,16 @@ def dataplep(rowdatadf, id=0, inboard=0.88, forceunit='lbs', bands=True, barchar
hr_bottom = 0.0*df[' HRCur (bpm)'], hr_bottom = 0.0*df[' HRCur (bpm)'],
) )
if 'check_factor' not in df.columns:
data = data.with_columns(
check_factor = pl.lit(0.0),
)
else:
data = data.with_columns(
check_factor = df['check_factor'],
)
if 'wash' not in df.columns: if 'wash' not in df.columns:
data = data.with_columns( data = data.with_columns(
wash = pl.lit(0.0), wash = pl.lit(0.0),

View File

@@ -1478,11 +1478,14 @@ class FusionMetricChoiceForm(ModelForm):
value in self.fields['columns'].choices} value in self.fields['columns'].choices}
for label in labeldict: for label in labeldict:
try:
if df.loc[:, label].std() == 0: if df.loc[:, label].std() == 0:
try: try:
formaxlabels2.pop(label) formaxlabels2.pop(label)
except KeyError: # pragma: no cover except KeyError: # pragma: no cover
pass pass
except KeyError: # pragma: no cover
formaxlabels2.pop(label)
metricchoices = list( metricchoices = list(
sorted(formaxlabels2.items(), key=lambda x: x[1])) sorted(formaxlabels2.items(), key=lambda x: x[1]))

View File

@@ -1841,6 +1841,8 @@ def interactive_flex_chart2(id, r, promember=0,
rowdata[column], 5)) rowdata[column], 5))
except KeyError: except KeyError:
pass pass
except ColumnNotFoundError:
pass
if len(rowdata) < 2: if len(rowdata) < 2:
if promember: if promember:
@@ -1938,10 +1940,10 @@ def interactive_flex_chart2(id, r, promember=0,
rowdata = rowdata.with_columns((pl.lit(axlabels[yparam2])).alias("yname2")) rowdata = rowdata.with_columns((pl.lit(axlabels[yparam2])).alias("yname2"))
except (KeyError, ColumnNotFoundError): # pragma: no cover except (KeyError, ColumnNotFoundError): # pragma: no cover
rowdata = rowdata.with_columns((pl.lit(yparam2)).alias("yname2")) rowdata = rowdata.with_columns((pl.lit(yparam2)).alias("yname2"))
else: # pragma: no cover else: # pragma: no cover
rowdata = rowdata.with_columns((pl.col("yname1")).alias("yname2")) rowdata = rowdata.with_columns((pl.col("yname1")).alias("yname2"))
def func(x, a, b): def func(x, a, b):
return a*x+b return a*x+b

View File

@@ -1,7 +1,7 @@
from rowers.utils import lbstoN from rowers.utils import lbstoN
import numpy as np import numpy as np
import yaml
import pandas as pd import pandas as pd
from scipy import optimize from scipy import optimize
from django.utils import timezone from django.utils import timezone
@@ -44,322 +44,16 @@ nometrics = [
'cum_dist.1' 'cum_dist.1'
] ]
rowingmetrics = ( with open("rowingmetrics.yaml", "r") as file:
('time', { td = yaml.safe_load(file)
'numtype': 'float',
'null': True,
'verbose_name': 'Time',
'ax_min': 0,
'ax_max': 1e5,
'mode': 'both',
'type': 'basic',
'group': 'basic',
'maysmooth': False,
'sigfigs': -1}),
('hr', {
'numtype': 'integer',
'null': True,
'verbose_name': 'Heart Rate (bpm)',
'ax_min': 100,
'ax_max': 200,
'mode': 'both',
'type': 'basic',
'maysmooth': False,
'group': 'athlete',
'sigfigs': 0, }),
('pace', {
'numtype': 'float',
'null': True,
'verbose_name': 'Pace (/500m)',
'ax_min': 1.0e3*210,
'ax_max': 1.0e3*75,
'mode': 'both',
'type': 'basic',
'sigfigs': 1,
'maysmooth': True,
'group': 'basic'}),
('velo', {
'numtype': 'float',
'null': True,
'verbose_name': 'Boat Speed (m/s)',
'ax_min': 0,
'ax_max': 8,
'default': 0,
'mode': 'both',
'sigfigs': 1,
'maysmooth': True,
'type': 'pro',
'group': 'basic'}),
# ('powerhr',{
# 'numtype':'float',
# 'null':True,
# 'verbose_name': 'Power Heart Rate Efficiency (J/beat)',
# 'ax_min':0,
# 'ax_max':300,
# 'mode':'both',
# 'type':'pro',
# 'group':'athlete'}),
('spm', {
'numtype': 'float',
'null': True,
'verbose_name': 'Stroke Rate (spm)',
'ax_min': 15,
'ax_max': 45,
'mode': 'both',
'sigfigs': 1,
'type': 'basic',
'maysmooth': True,
'group': 'basic'}),
('driveenergy', {
'numtype': 'float',
'null': True,
'verbose_name': 'Work Per Stroke (J)',
'ax_min': 0,
'ax_max': 1000,
'mode': 'both',
'sigfigs': 0,
'type': 'pro',
'maysmooth': True,
'group': 'forcepower'}),
('power', {
'numtype': 'float',
'null': True,
'verbose_name': 'Power (W)',
'ax_min': 0,
'ax_max': 600,
'mode': 'both',
'sigfigs': 0,
'type': 'basic',
'maysmooth': True,
'group': 'forcepower'}),
('averageforce', {
'numtype': 'float',
'null': True,
'verbose_name': 'Average Drive Force (N)',
'ax_min': 0,
'ax_max': 1200,
'mode': 'both',
'sigfigs': 0,
'maysmooth': True,
'type': 'pro',
'group': 'forcepower'}),
('peakforce', {
'numtype': 'float',
'null': True,
'verbose_name': 'Peak Drive Force (N)',
'ax_min': 0,
'ax_max': 1500,
'sigfigs': 0,
'mode': 'both',
'maysmooth': True,
'type': 'pro',
'group': 'forcepower'}),
('drivelength', {
'numtype': 'float',
'null': True,
'verbose_name': 'Drive Length (m)',
'ax_min': 0,
'ax_max': 2.5,
'mode': 'rower',
'sigfigs': 2,
'maysmooth': False,
'type': 'pro',
'group': 'stroke'}),
('forceratio', {
'numtype': 'float',
'null': True,
'verbose_name': 'Average/Peak Force Ratio',
'ax_min': 0,
'ax_max': 1,
'sigfigs': 2,
'maysmooth': True,
'mode': 'both',
'type': 'pro',
'group': 'forcepower'}),
('distance', {
'numtype': 'float',
'null': True,
'verbose_name': 'Interval Distance (m)',
'ax_min': 0,
'ax_max': 1e5,
'sigfigs': 0,
'mode': 'both',
'maysmooth': False,
'type': 'basic',
'group': 'basic'}),
('cumdist', {
'numtype': 'float',
'null': True,
'verbose_name': 'Cumulative Distance (m)',
'ax_min': 0,
'ax_max': 1e5,
'mode': 'both',
'sigfigs': 0,
'maysmooth': False,
'type': 'basic',
'group': 'basic'}),
('drivespeed', {
'numtype': 'float',
'null': True,
'verbose_name': 'Drive Speed (m/s)',
'ax_min': 0,
'ax_max': 4,
'default': 0,
'sigfigs': 2,
'maysmooth': True,
'mode': 'both',
'type': 'pro',
'group': 'stroke'}),
('catch', {
'numtype': 'float',
'null': True,
'verbose_name': 'Catch Angle (degrees)',
'ax_min': -40,
'ax_max': -75,
'default': 0,
'sigfigs': 0,
'mode': 'water',
'maysmooth': True,
'type': 'pro',
'group': 'stroke'}),
('slip', {
'numtype': 'float',
'null': True,
'verbose_name': 'Slip (degrees)',
'ax_min': 0,
'ax_max': 20,
'default': 0,
'sigfigs': 1,
'maysmooth': True,
'mode': 'water',
'type': 'pro',
'group': 'stroke'}),
('finish', {
'numtype': 'float',
'null': True,
'verbose_name': 'Finish Angle (degrees)',
'ax_min': 20,
'ax_max': 55,
'default': 0,
'sigfigs': 0,
'maysmooth': True,
'mode': 'water',
'type': 'pro',
'group': 'stroke'}),
('wash', {
'numtype': 'float',
'null': True,
'verbose_name': 'Wash (degrees)',
'ax_min': 0,
'ax_max': 30,
'default': 0,
'sigfigs': 1,
'maysmooth': True,
'mode': 'water',
'type': 'pro',
'group': 'stroke'}),
('peakforceangle', {
'numtype': 'float',
'null': True,
'verbose_name': 'Peak Force Angle',
'ax_min': -50,
'ax_max': 50,
'default': 0,
'sigfigs': 0,
'maysmooth': True,
'mode': 'water',
'type': 'pro',
'group': 'stroke'}),
('totalangle', {
'numtype': 'float',
'null': True,
'verbose_name': 'Drive Length (deg)',
'ax_min': 40,
'ax_max': 140,
'default': 0,
'sigfigs': 0,
'maysmooth': True,
'mode': 'water',
'type': 'pro',
'group': 'stroke'}),
('effectiveangle', {
'numtype': 'float',
'null': True,
'verbose_name': 'Effective Drive Length (deg)',
'ax_min': 40,
'ax_max': 140,
'default': 0,
'sigfigs': 0,
'mode': 'water',
'maysmooth': True,
'type': 'pro',
'group': 'stroke'}),
('rhythm', {
'numtype': 'float',
'null': True,
'verbose_name': 'Stroke Rhythm',
'ax_min': 20,
'ax_max': 55,
'default': 1.0,
'sigfigs': 0,
'mode': 'erg',
'maysmooth': True,
'type': 'pro',
'group': 'stroke'}),
('efficiency', {
'numtype': 'float',
'null': True,
'verbose_name': 'OTW Efficiency (%)',
'ax_min': 0,
'ax_max': 110,
'default': 0,
'sigfigs': 0,
'mode': 'water',
'maysmooth': True,
'type': 'pro',
'group': 'forcepower'}),
('distanceperstroke', {
'numtype': 'float',
'null': True,
'verbose_name': 'Distance per Stroke (m)',
'ax_min': 0,
'ax_max': 15,
'default': 0,
'sigfigs': 1,
'maysmooth': True,
'mode': 'both',
'type': 'basic',
'group': 'basic'}),
rowingmetrics = tuple(
(key, value)
for key, value in td.items()
) )
metricsdicts = {} metricsdicts = {}
for key, dict in rowingmetrics: for key, dict in rowingmetrics:
metricsdicts[key] = dict metricsdicts[key] = dict

View File

@@ -27,7 +27,7 @@ from django.contrib.admin.widgets import AdminDateWidget, AdminTimeWidget, Admin
import os import os
import json import json
import ssl import ssl
import secrets
import re import re
import pytz import pytz
@@ -923,7 +923,18 @@ class CoachingGroup(models.Model):
return Rower.objects.filter(mycoachgroup=self) return Rower.objects.filter(mycoachgroup=self)
# Extension of User with rowing specific data # Extension of User with rowing specific data
class APIKey(models.Model):
key = models.CharField(max_length=50, unique=True, default=secrets.token_urlsafe)
user = models.ForeignKey('auth.User', on_delete=models.CASCADE)
created_at = models.DateTimeField(auto_now_add=True)
is_active = models.BooleanField(default=True)
def __str__(self):
return f"{self.user.username}:{self.key}"
def regenerate_key(self):
self.key = secrets.token_urlsafe()
self.save()
class Rower(models.Model): class Rower(models.Model):
adaptivetypes = mytypes.adaptivetypes adaptivetypes = mytypes.adaptivetypes

View File

@@ -888,6 +888,10 @@ def get_dates_timeperiod(request, startdatestring='', enddatestring='',
startdate = timezone.now()-timezone.timedelta(days=5) startdate = timezone.now()-timezone.timedelta(days=5)
startdate = startdate.date() startdate = startdate.date()
enddate = timezone.now().date() enddate = timezone.now().date()
except parser.ParserError:
startdate = timezone.now()-timezone.timedelta(days=5)
startdate = startdate.date()
enddate = timezone.now().date()
if startdate > enddate: if startdate > enddate:
e = startdate e = startdate

View File

@@ -7,7 +7,9 @@
{% block main %} {% block main %}
<h1>Import and Export Settings for {{ rower.user.first_name }} {{ rower.user.last_name }}</h1> <h1>Import and Export Settings for {{ rower.user.first_name }} {{ rower.user.last_name }}</h1>
<p>You are currently connected to: <ul class="main-content">
<li class="grid_2">
<p>You are currently connected to:
{% if rower.c2token is not None and rower.c2token != '' %} {% if rower.c2token is not None and rower.c2token != '' %}
Concept2 Logbook, Concept2 Logbook,
{% endif %} {% endif %}
@@ -35,8 +37,7 @@
{% if rower.rojabo_token is not None and rower.rojabo_token != '' %} {% if rower.rojabo_token is not None and rower.rojabo_token != '' %}
Rojabo Rojabo
{% endif %} {% endif %}
</p>
</p>
{% if form.errors %} {% if form.errors %}
<p style="color: red;"> <p style="color: red;">
@@ -71,29 +72,58 @@
a workout on Strava. If you want Deletions to propagate to Rowsandall, tick the Strava Auto Delete a workout on Strava. If you want Deletions to propagate to Rowsandall, tick the Strava Auto Delete
check box. check box.
</p> </p>
<p>Click on one of the icons below to connect to the service of your </li>
<li class="grid_2">
{% if grants %}
<h2>Applications</h2>
<table width="100%">
<thead>
<tr>
<th>Application</th>
<th>Scope</th>
<th>Revoke</th>
</tr>
</thead>
<tbody>
{% for grant in grants %}
<tr>
<td>{{ grant.application }}</td>
<td>{{ grant.scope }}</td>
<td>
<a href="/rowers/me/revokeapp/{{ grant.application.id }}/">Revoke</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<h2>API Key</h2>
<p>{{ apikey }}</p>
<p>
<a href="/rowers/me/regenerateapikey/">Regenerate</a>
</p>
This API key can be used to access the Rowsandall API. It is used by some third party applications to access your data. Keep it secret.
</li>
</ul>
<p>Click on one of the icons below to connect to the service of your
choice or to renew the authorization.</p> choice or to renew the authorization.</p>
<p><a href="/rowers/me/stravaauthorize/"><img src="/static/img/ConnectWithStrava.png" alt="connect with strava" width="120"></a></p> <p><a href="/rowers/me/stravaauthorize/"><img src="/static/img/ConnectWithStrava.png" alt="connect with strava" width="120"></a></p>
<p><a href="/rowers/me/c2authorize/"><img src="/static/img/blueC2logo.png" alt="connect with Concept2" width="120"></a></p> <p><a href="/rowers/me/c2authorize/"><img src="/static/img/blueC2logo.png" alt="connect with Concept2" width="120"></a></p>
<p><a href="/rowers/me/nkauthorize/"><img src="/static/img/NKLiNKLogbook.png" alt="connect with NK Logbook" width="120"></a></p> <p><a href="/rowers/me/nkauthorize/"><img src="/static/img/NKLiNKLogbook.png" alt="connect with NK Logbook" width="120"></a></p>
<p><a href="/rowers/me/sporttracksauthorize/"><img src="/static/img/sporttracks-button.png" alt="connect with SportTracks" width="120"></a></p> <p><a href="/rowers/me/sporttracksauthorize/"><img src="/static/img/sporttracks-button.png" alt="connect with SportTracks" width="120"></a></p>
<p><a href="/rowers/me/polarauthorize/"><img src="/static/img/Polar_connectwith_btn_white.png" <p><a href="/rowers/me/polarauthorize/"><img src="/static/img/Polar_connectwith_btn_white.png"
alt="connect with Polar" width="130"></a></p> alt="connect with Polar" width="130"></a></p>
<p><a href="/rowers/me/tpauthorize/"><img src="/static/img/TP_logo_horz_2_color.png" <p><a href="/rowers/me/tpauthorize/"><img src="/static/img/TP_logo_horz_2_color.png"
alt="connect with Polar" width="130"></a></p> alt="connect with Polar" width="130"></a></p>
<p><a href="/rowers/me/garminauthorize"><img src="/static/img/garmin_badge_130.png" <p><a href="/rowers/me/garminauthorize"><img src="/static/img/garmin_badge_130.png"
alt="connect with Garmin" width="130"></a></p> alt="connect with Garmin" width="130"></a></p>
<p><a href="/rowers/me/rp3authorize"><img src="/static/img/logo-rp3-full-black.png" <p><a href="/rowers/me/rp3authorize"><img src="/static/img/logo-rp3-full-black.png"
alt="connect with RP3" width="130"></a></p> alt="connect with RP3" width="130"></a></p>
<p><a href="/rowers/me/rojaboauthorize"><img src="/static/img/rojabo.png" <p><a href="/rowers/me/rojaboauthorize"><img src="/static/img/rojabo.png"
alt="connect with Rojabo" width="130"></a></p> alt="connect with Rojabo" width="130"></a></p>
{% if user.is_staff %}
<p><a href="/rowers/me/idokladauthorize/">iDoklad authorize</a></p>
{% endif %}
{% endblock %} {% endblock %}
{% block sidebar %} {% block sidebar %}

View File

@@ -116,6 +116,14 @@
</table> </table>
{% endif %} {% endif %}
</li> </li>
<li class="grid_2">
<h2>API Key</h2>
<p>{{ apikey }}</p>
<p>
<a href="/rowers/me/regenerateapikey/">Regenerate</a>
</p>
This API key can be used to access the Rowsandall API. It is used by some third party applications to access your data. Keep it secret.
</li>
</ul> </ul>
{% endif %} {% endif %}

View File

@@ -26,6 +26,7 @@ import json
from rowers.ownapistuff import * from rowers.ownapistuff import *
from rowers.views.apiviews import * from rowers.views.apiviews import *
from rowers.models import APIKey
class OwnApi(TestCase): class OwnApi(TestCase):
def setUp(self): def setUp(self):
@@ -44,9 +45,8 @@ class OwnApi(TestCase):
self.password = faker.word() self.password = faker.word()
self.u.set_password(self.password) self.u.set_password(self.password)
self.u.save() self.u.save()
self.factory = APIRequestFactory() self.factory = APIRequestFactory()
self.apikey = APIKey.objects.create(user=self.u)
def test_strokedataform(self): def test_strokedataform(self):
login = self.c.login(username=self.u.username, password=self.password) login = self.c.login(username=self.u.username, password=self.password)
@@ -210,6 +210,75 @@ class OwnApi(TestCase):
response = strokedata_tcx(request) response = strokedata_tcx(request)
self.assertEqual(response.status_code,200) self.assertEqual(response.status_code,200)
def test_strokedataform_rowingdata(self):
url = reverse('strokedata_rowingdata')
filename = 'rowers/tests/testdata/testdata.csv'
f = open(filename, 'rb')
# Use Basic Auth header (alternative if using a custom view)
credentials = f"{self.u.username}:{self.password}"
base64_credentials = base64.b64encode(credentials.encode()).decode()
# Use Basic Auth header
headers = {
"HTTP_AUTHORIZATION": f"Basic {base64_credentials}"
}
form_data = {
"workouttype": "rower",
"boattype": "1x",
"notes": "A test file upload",
}
# Send POST request
response = self.client.post(
url,
{"file": f, **form_data},
format="multipart", # Ensure multipart/form-data is used
**headers, # Optional if login doesn't suffice
)
f.close()
# Assertions
self.assertEqual(response.status_code, 201)
def test_strokedataform_rowingdata_apikey(self):
url = reverse('strokedata_rowingdata_apikey')
filename = 'rowers/tests/testdata/testdata.csv'
f = open(filename, 'rb')
# Use API Key header
headers = {
"HTTP_AUTHORIZATION": self.apikey.key,
}
form_data = {
"workouttype": "rower",
"boattype": "1x",
"notes": "A test file upload",
}
# Send POST request
response = self.client.post(
url,
{"file": f, **form_data},
format="multipart", # Ensure multipart/form-data is used
**headers, # Optional if login doesn't suffice
)
f.close()
# Assertions
self.assertEqual(response.status_code, 201)
def test_strokedataform_empty(self): def test_strokedataform_empty(self):
login = self.c.login(username=self.u.username, password=self.password) login = self.c.login(username=self.u.username, password=self.password)
self.assertTrue(login) self.assertTrue(login)

View File

@@ -254,6 +254,10 @@ urlpatterns = [
name='strokedatajson_v3'), name='strokedatajson_v3'),
re_path(r'^api/TCX/workouts/$', views.strokedata_tcx, re_path(r'^api/TCX/workouts/$', views.strokedata_tcx,
name='strokedata_tcx'), name='strokedata_tcx'),
re_path(r'^api/rowingdata/workouts/$', views.strokedata_rowingdata,
name='strokedata_rowingdata'),
re_path(r'^api/rowingdata/$', views.strokedata_rowingdata_apikey,
name='strokedata_rowingdata_apikey'),
re_path(r'^api/courses/$', views.course_list, name='course_list'), re_path(r'^api/courses/$', views.course_list, name='course_list'),
re_path(r'^api/courses/(?P<id>\d+)/$', views.get_crewnerd_kml, name='get_crewnerd_kml'), re_path(r'^api/courses/(?P<id>\d+)/$', views.get_crewnerd_kml, name='get_crewnerd_kml'),
re_path(r'^api/courses/kml/liked/$', views.get_crewnerd_liked, name='get_crewnerd_liked'), re_path(r'^api/courses/kml/liked/$', views.get_crewnerd_liked, name='get_crewnerd_liked'),
@@ -733,6 +737,7 @@ urlpatterns = [
re_path(r'^me/request/$', views.manager_requests_view, re_path(r'^me/request/$', views.manager_requests_view,
name='manager_requests_view'), name='manager_requests_view'),
re_path(r'^me/edit/$', views.rower_edit_view, name='rower_edit_view'), re_path(r'^me/edit/$', views.rower_edit_view, name='rower_edit_view'),
re_path(r'^me/regenerateapikey/$', views.rower_regenerate_apikey, name='rower_regenerate_apikey'),
re_path(r'^me/edit/user/(?P<userid>\d+)/$', re_path(r'^me/edit/user/(?P<userid>\d+)/$',
views.rower_edit_view, name='rower_edit_view'), views.rower_edit_view, name='rower_edit_view'),
re_path(r'^me/preferences/$', views.rower_prefs_view, re_path(r'^me/preferences/$', views.rower_prefs_view,

View File

@@ -2282,11 +2282,11 @@ def history_view_data(request, userid=0):
try: try:
ddict['hrmean'] = int(wavg(ddf, 'hr', 'deltat')) ddict['hrmean'] = int(wavg(ddf, 'hr', 'deltat'))
except (KeyError, ValueError, AttributeError): # pragma: no cover except (KeyError, ValueError, AttributeError, ColumnNotFoundError): # pragma: no cover
ddict['hrmean'] = 0 ddict['hrmean'] = 0
try: try:
ddict['hrmax'] = int(ddf['hr'].max()) ddict['hrmax'] = int(ddf['hr'].max())
except (KeyError, ValueError, AttributeError): # pragma: no cover except (KeyError, ValueError, AttributeError, ColumnNotFoundError): # pragma: no cover
ddict['hrmax'] = 0 ddict['hrmax'] = 0
ddict['powermean'] = int(wavg(ddf, 'power', 'deltat')) ddict['powermean'] = int(wavg(ddf, 'power', 'deltat'))

View File

@@ -2,7 +2,8 @@ from rowers.views.statements import *
from rowers.tasks import handle_calctrimp from rowers.tasks import handle_calctrimp
from rowers.opaque import encoder from rowers.opaque import encoder
from rowers.courses import coursetokml, coursestokml from rowers.courses import coursetokml, coursestokml
from xml.etree import ElementTree as ET #from xml.etree import ElementTree as ET
from defusedxml import ElementTree as ET
import arrow import arrow
import pendulum import pendulum
@@ -21,6 +22,8 @@ import rowingdata.tcxtools as tcxtools
from rowingdata import TCXParser, rowingdata from rowingdata import TCXParser, rowingdata
import arrow import arrow
import base64
class XMLParser(BaseParser): class XMLParser(BaseParser):
media_type = "application/xml" media_type = "application/xml"
@@ -28,6 +31,8 @@ class XMLParser(BaseParser):
dologging("apilog.log", "XML Parser") dologging("apilog.log", "XML Parser")
try: try:
s = ET.parse(stream).getroot() s = ET.parse(stream).getroot()
except ET.XMLSyntaxError:
return HttpResponse(status=400)
except Exception as e: # pragma: no cover except Exception as e: # pragma: no cover
dologging("apilog.log",e) dologging("apilog.log",e)
return HttpResponse(status=500) return HttpResponse(status=500)
@@ -422,6 +427,110 @@ def get_crewnerd_liked(request):
# Stroke data views # Stroke data views
@csrf_exempt
@logged_in_or_basicauth()
#@api_view(["POST"])
#@permission_required('rower.is_not_freecoach', fn=get_user_by_userid, raise_exception=True)
#@permission_classes([IsAuthenticated])
def strokedata_rowingdata(request):
"""
Upload a .csv file (rowingdata standard) through API, using Basic Auth
"""
r = getrower(request.user)
if r.rowerplan == 'freecoach':
return HttpResponseNotAllowed("This endpoint is for users, not for free coach accounts")
if request.method != 'POST':
return HttpResponseNotAllowed("Method not supported") # pragma: no cover
form = DocumentsForm(request.POST, request.FILES)
if not form.is_valid():
return HttpResponseBadRequest(json.dumps(form.errors))
f = form.cleaned_data['file']
if f is None:
return HttpResponseBadRequest("Missing file")
filename, completefilename = handle_uploaded_file(f)
uploadoptions = {
'secret': settings.UPLOAD_SERVICE_SECRET,
'user': r.user.id,
'file': completefilename,
'workouttype': form.cleaned_data['workouttype'],
'boattype': form.cleaned_data['boattype'],
'title': form.cleaned_data['title'],
'rpe': form.cleaned_data['rpe'],
'notes': form.cleaned_data['notes']
}
url = settings.UPLOAD_SERVICE_URL
_ = myqueue(queuehigh,
handle_request_post,
url,
uploadoptions)
response = JsonResponse(
{
"status": "success",
}
)
response.status_code = 201
return response
@csrf_exempt
@api_view(["POST"])
@permission_required('rower.is_not_freecoach', fn=get_user_by_userid, raise_exception=True)
@permission_classes([IsAuthenticated])
def strokedata_rowingdata_apikey(request):
"""
Upload a .csv file (rowingdata standard) through API, using
"""
r = getrower(request.user)
if r.rowerplan == 'freecoach':
return HttpResponseNotAllowed("This endpoint is for users, not for free coach accounts")
if request.method != 'POST':
return HttpResponseNotAllowed("Method not supported")
form = DocumentsForm(request.POST, request.FILES)
if not form.is_valid():
return HttpResponseBadRequest(json.dumps(form.errors))
f = form.cleaned_data['file']
if f is None:
return HttpResponseBadRequest("Missing file")
filename, completefilename = handle_uploaded_file(f)
uploadoptions = {
'secret': settings.UPLOAD_SERVICE_SECRET,
'user': r.user.id,
'file': completefilename,
'workouttype': form.cleaned_data['workouttype'],
'boattype': form.cleaned_data['boattype'],
'title': form.cleaned_data['title'],
'rpe': form.cleaned_data['rpe'],
'notes': form.cleaned_data['notes']
}
url = settings.UPLOAD_SERVICE_URL
_ = myqueue(queuehigh,
handle_request_post,
url,
uploadoptions)
response = JsonResponse(
{
"status": "success",
}
)
response.status_code = 201
return response
@csrf_exempt @csrf_exempt
#@login_required() #@login_required()
@api_view(["POST"]) @api_view(["POST"])

View File

@@ -93,7 +93,8 @@ from django.http import (
HttpResponse, HttpResponseRedirect, HttpResponse, HttpResponseRedirect,
JsonResponse, JsonResponse,
HttpResponseForbidden, HttpResponseNotAllowed, HttpResponseForbidden, HttpResponseNotAllowed,
HttpResponseNotFound, Http404 HttpResponseNotFound, Http404,
HttpResponseBadRequest,
) )
from django.contrib.auth import authenticate, login, logout from django.contrib.auth import authenticate, login, logout
from rowers.forms import ( from rowers.forms import (
@@ -163,7 +164,7 @@ from rowers.models import (
StandardCollection, CourseStandard, StandardCollection, CourseStandard,
VirtualRaceFollower, TombStone, InstantPlan, VirtualRaceFollower, TombStone, InstantPlan,
PlannedSessionStep,InStrokeAnalysis, ForceCurveAnalysis, SyncRecord, PlannedSessionStep,InStrokeAnalysis, ForceCurveAnalysis, SyncRecord,
UserMessage, UserMessage,APIKey,
) )
from rowers.models import ( RowerPowerForm, RowerHRZonesForm, SimpleRowerPowerForm, from rowers.models import ( RowerPowerForm, RowerHRZonesForm, SimpleRowerPowerForm,
RowerForm, RowerCPForm, GraphImage, AdvancedWorkoutForm, RowerForm, RowerCPForm, GraphImage, AdvancedWorkoutForm,
@@ -303,6 +304,142 @@ from rowers.weather import get_wind_data, get_airport_code, get_metar_data
from oauth2_provider.models import Application, Grant, AccessToken from oauth2_provider.models import Application, Grant, AccessToken
import base64
from django.http import HttpResponse
from django.contrib.auth import authenticate, login
def view_or_apikey(view, request, test_func, realm = "", *args, **kwargs):
if test_func(request.user):
return view(request, *args, **kwargs)
if 'Authorization' in request.META:
api_key = request.META.get('Authorization')
if api_key:
try:
api_key = APIKey.objects.get(key=api_key, is_active=True)
except APIKey.DoesNotExist:
raise AuthenticationFailed('Invalid API key')
login(request, api_key.user, backend='django.contrib.auth.backends.ModelBackend')
request.user = api_key.user
return view(request, *args, **kwargs)
response = HttpResponse()
response.status_code = 401
response['WWW-Authenticate'] = 'Basic realm="%s"' % realm
return response
#############################################################################
#
def view_or_basicauth(view, request, test_func, realm = "", *args, **kwargs):
"""
This is a helper function used by both 'logged_in_or_basicauth' and
'has_perm_or_basicauth' that does the nitty of determining if they
are already logged in or if they have provided proper http-authorization
and returning the view if all goes well, otherwise responding with a 401.
"""
if test_func(request.user):
# Already logged in, just return the view.
#
return view(request, *args, **kwargs)
# They are not logged in. See if they provided login credentials
#
if 'HTTP_AUTHORIZATION' in request.META:
auth = request.META['HTTP_AUTHORIZATION'].split()
if len(auth) == 2:
# NOTE: We are only support basic authentication for now.
#
if auth[0].lower() == "basic":
uname, passwd = base64.b64decode(auth[1]).decode("utf-8").split(':')
user = authenticate(username=uname, password=passwd)
if user is not None:
if user.is_active:
login(request, user)
request.user = user
return view(request, *args, **kwargs)
# Either they did not provide an authorization header or
# something in the authorization attempt failed. Send a 401
# back to them to ask them to authenticate.
#
response = HttpResponse()
response.status_code = 401
response['WWW-Authenticate'] = 'Basic realm="%s"' % realm
return response
#############################################################################
#
def logged_in_or_apikey(realm = ""):
def view_decorator(func):
def wrapper(request, *args, **kwargs):
return view_or_apikey(func, request,
lambda u: u.is_authenticated,
realm, *args, **kwargs)
return wrapper
return view_decorator
def logged_in_or_basicauth(realm = ""):
"""
A simple decorator that requires a user to be logged in. If they are not
logged in the request is examined for a 'authorization' header.
If the header is present it is tested for basic authentication and
the user is logged in with the provided credentials.
If the header is not present a http 401 is sent back to the
requestor to provide credentials.
The purpose of this is that in several django projects I have needed
several specific views that need to support basic authentication, yet the
web site as a whole used django's provided authentication.
The uses for this are for urls that are access programmatically such as
by rss feed readers, yet the view requires a user to be logged in. Many rss
readers support supplying the authentication credentials via http basic
auth (and they do NOT support a redirect to a form where they post a
username/password.)
Use is simple:
@logged_in_or_basicauth
def your_view:
...
You can provide the name of the realm to ask for authentication within.
"""
def view_decorator(func):
def wrapper(request, *args, **kwargs):
return view_or_basicauth(func, request,
lambda u: u.is_authenticated,
realm, *args, **kwargs)
return wrapper
return view_decorator
#############################################################################
#
def has_perm_or_basicauth(perm, realm = ""):
"""
This is similar to the above decorator 'logged_in_or_basicauth'
except that it requires the logged in user to have a specific
permission.
Use:
@logged_in_or_basicauth('asforums.view_forumcollection')
def your_view:
...
"""
def view_decorator(func):
def wrapper(request, *args, **kwargs):
return view_or_basicauth(func, request,
lambda u: u.has_perm(perm),
realm, *args, **kwargs)
return wrapper
return view_decorator
import django_rq import django_rq
queue = django_rq.get_queue('default') queue = django_rq.get_queue('default')
queuelow = django_rq.get_queue('low') queuelow = django_rq.get_queue('low')

View File

@@ -520,10 +520,19 @@ def rower_exportsettings_view(request, userid=0):
} }
] ]
grants = AccessToken.objects.filter(user=request.user)
try:
apikey = APIKey.objects.get(user=request.user)
except APIKey.DoesNotExist:
apikey = APIKey.objects.create(user=request.user)
return render(request, 'rower_exportsettings.html', return render(request, 'rower_exportsettings.html',
{'form': form, {'form': form,
'rower': r, 'rower': r,
'breadcrumbs': breadcrumbs, 'breadcrumbs': breadcrumbs,
'grants': grants,
'apikey': apikey.key,
}) })
@@ -632,6 +641,12 @@ def rower_edit_view(request, rowerid=0, userid=0, message=""):
userform = UserForm(instance=r.user) userform = UserForm(instance=r.user)
grants = AccessToken.objects.filter(user=request.user) grants = AccessToken.objects.filter(user=request.user)
try:
apikey = APIKey.objects.get(user=request.user)
except APIKey.DoesNotExist:
apikey = APIKey.objects.create(user=request.user)
return render(request, 'rower_form.html', return render(request, 'rower_form.html',
{ {
'teams': get_my_teams(request.user), 'teams': get_my_teams(request.user),
@@ -640,8 +655,24 @@ def rower_edit_view(request, rowerid=0, userid=0, message=""):
'userform': userform, 'userform': userform,
'accountform': accountform, 'accountform': accountform,
'rower': r, 'rower': r,
'apikey': apikey.key,
}) })
@login_required()
def rower_regenerate_apikey(request):
try:
referer = request.META['HTTP_REFERER']
except KeyError:
referer = '/rowers/me/edit/'
try:
apikey = APIKey.objects.get(user=request.user)
except APIKey.DoesNotExist:
apikey = APIKey.objects.create(user=request.user)
apikey.regenerate_key()
return HttpResponseRedirect(referer)
#simple initial settings page #simple initial settings page
@login_required() @login_required()
@permission_required('rower.is_coach', fn=get_user_by_userid, raise_exception=True) @permission_required('rower.is_coach', fn=get_user_by_userid, raise_exception=True)

View File

@@ -4931,7 +4931,6 @@ def workout_upload_api(request):
post_data = {k: q.getlist(k) if len( post_data = {k: q.getlist(k) if len(
q.getlist(k)) > 1 else v for k, v in q.items()} q.getlist(k)) > 1 else v for k, v in q.items()}
# only allow local host # only allow local host
hostt = request.get_host().split(':') hostt = request.get_host().split(':')
if hostt[0] not in ['localhost', '127.0.0.1', 'dev.rowsandall.com', 'rowsandall.com']: if hostt[0] not in ['localhost', '127.0.0.1', 'dev.rowsandall.com', 'rowsandall.com']:

View File

@@ -2,7 +2,8 @@ import requests
from requests.exceptions import ConnectionError from requests.exceptions import ConnectionError
import json import json
from lxml import objectify, etree from lxml import objectify, etree
import xml.etree.ElementTree as ET #import xml.etree.ElementTree as ET
from defusedxml import ElementTree as ET
import time import time
from datetime import datetime from datetime import datetime
from rowingdata import rowingdata, geo_distance from rowingdata import rowingdata, geo_distance

288
rowingmetrics.yaml Normal file
View File

@@ -0,0 +1,288 @@
time:
numtype: float
'null': true
verbose_name: Time
ax_min: 0
ax_max: 100000.0
mode: both
type: basic
group: basic
maysmooth: false
sigfigs: -1
hr:
numtype: integer
'null': true
verbose_name: Heart Rate (bpm)
ax_min: 100
ax_max: 200
mode: both
type: basic
maysmooth: false
group: athlete
sigfigs: 0
pace:
numtype: float
'null': true
verbose_name: Pace (/500m)
ax_min: 210000.0
ax_max: 75000.0
mode: both
type: basic
sigfigs: 1
maysmooth: true
group: basic
velo:
numtype: float
'null': true
verbose_name: Boat Speed (m/s)
ax_min: 0
ax_max: 8
default: 0
mode: both
sigfigs: 1
maysmooth: true
type: pro
group: basic
spm:
numtype: float
'null': true
verbose_name: Stroke Rate (spm)
ax_min: 15
ax_max: 45
mode: both
sigfigs: 1
type: basic
maysmooth: true
group: basic
driveenergy:
numtype: float
'null': true
verbose_name: Work Per Stroke (J)
ax_min: 0
ax_max: 1000
mode: both
sigfigs: 0
type: pro
maysmooth: true
group: forcepower
power:
numtype: float
'null': true
verbose_name: Power (W)
ax_min: 0
ax_max: 600
mode: both
sigfigs: 0
type: basic
maysmooth: true
group: forcepower
averageforce:
numtype: float
'null': true
verbose_name: Average Drive Force (N)
ax_min: 0
ax_max: 1200
mode: both
sigfigs: 0
maysmooth: true
type: pro
group: forcepower
peakforce:
numtype: float
'null': true
verbose_name: Peak Drive Force (N)
ax_min: 0
ax_max: 1500
sigfigs: 0
mode: both
maysmooth: true
type: pro
group: forcepower
drivelength:
numtype: float
'null': true
verbose_name: Drive Length (m)
ax_min: 0
ax_max: 2.5
mode: rower
sigfigs: 2
maysmooth: false
type: pro
group: stroke
forceratio:
numtype: float
'null': true
verbose_name: Average/Peak Force Ratio
ax_min: 0
ax_max: 1
sigfigs: 2
maysmooth: true
mode: both
type: pro
group: forcepower
distance:
numtype: float
'null': true
verbose_name: Interval Distance (m)
ax_min: 0
ax_max: 100000.0
sigfigs: 0
mode: both
maysmooth: false
type: basic
group: basic
cumdist:
numtype: float
'null': true
verbose_name: Cumulative Distance (m)
ax_min: 0
ax_max: 100000.0
mode: both
sigfigs: 0
maysmooth: false
type: basic
group: basic
drivespeed:
numtype: float
'null': true
verbose_name: Drive Speed (m/s)
ax_min: 0
ax_max: 4
default: 0
sigfigs: 2
maysmooth: true
mode: both
type: pro
group: stroke
catch:
numtype: float
'null': true
verbose_name: Catch Angle (degrees)
ax_min: -40
ax_max: -75
default: 0
sigfigs: 0
mode: water
maysmooth: true
type: pro
group: stroke
slip:
numtype: float
'null': true
verbose_name: Slip (degrees)
ax_min: 0
ax_max: 20
default: 0
sigfigs: 1
maysmooth: true
mode: water
type: pro
group: stroke
finish:
numtype: float
'null': true
verbose_name: Finish Angle (degrees)
ax_min: 20
ax_max: 55
default: 0
sigfigs: 0
maysmooth: true
mode: water
type: pro
group: stroke
wash:
numtype: float
'null': true
verbose_name: Wash (degrees)
ax_min: 0
ax_max: 30
default: 0
sigfigs: 1
maysmooth: true
mode: water
type: pro
group: stroke
peakforceangle:
numtype: float
'null': true
verbose_name: Peak Force Angle
ax_min: -50
ax_max: 50
default: 0
sigfigs: 0
maysmooth: true
mode: water
type: pro
group: stroke
totalangle:
numtype: float
'null': true
verbose_name: Drive Length (deg)
ax_min: 40
ax_max: 140
default: 0
sigfigs: 0
maysmooth: true
mode: water
type: pro
group: stroke
check_factor:
numtype: float
'null': true
verbose_name: Check Factor
ax_min: 0
ax_max: 100
default: 0
sigfigs: 1
maysmooth: true
mode: water
type: pro
group: stroke
effectiveangle:
numtype: float
'null': true
verbose_name: Effective Drive Length (deg)
ax_min: 40
ax_max: 140
default: 0
sigfigs: 0
mode: water
maysmooth: true
type: pro
group: stroke
rhythm:
numtype: float
'null': true
verbose_name: Stroke Rhythm
ax_min: 20
ax_max: 55
default: 1.0
sigfigs: 0
mode: erg
maysmooth: true
type: pro
group: stroke
efficiency:
numtype: float
'null': true
verbose_name: OTW Efficiency (%)
ax_min: 0
ax_max: 110
default: 0
sigfigs: 0
mode: water
maysmooth: true
type: pro
group: forcepower
distanceperstroke:
numtype: float
'null': true
verbose_name: Distance per Stroke (m)
ax_min: 0
ax_max: 15
default: 0
sigfigs: 1
maysmooth: true
mode: both
type: basic
group: basic

View File

@@ -483,10 +483,11 @@ REST_FRAMEWORK = {
# 'rest_framework.permissions.DjangoModelPermissions' # 'rest_framework.permissions.DjangoModelPermissions'
], ],
'DEFAULT_AUTHENTICATION_CLASSES': ( 'DEFAULT_AUTHENTICATION_CLASSES': (
'rest_framework.authentication.BasicAuthentication', #'rest_framework.authentication.BasicAuthentication',
# 'rest_framework.authentication.SessionAuthentication', # 'rest_framework.authentication.SessionAuthentication',
# 'rest_framework.authentication.TokenAuthentication', # 'rest_framework.authentication.TokenAuthentication',
'oauth2_provider.contrib.rest_framework.OAuth2Authentication', 'oauth2_provider.contrib.rest_framework.OAuth2Authentication',
'rowers.authentication.APIKeyAuthentication',
), ),
'PAGE_SIZE': 20, 'PAGE_SIZE': 20,
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination', 'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',