diff --git a/rowers/authentication.py b/rowers/authentication.py
new file mode 100644
index 00000000..e5cf818e
--- /dev/null
+++ b/rowers/authentication.py
@@ -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)
diff --git a/rowers/dataroutines.py b/rowers/dataroutines.py
index 422226bd..9360b1b7 100644
--- a/rowers/dataroutines.py
+++ b/rowers/dataroutines.py
@@ -176,6 +176,7 @@ columndict = {
'slip': 'slip',
'workoutstate': ' WorkoutState',
'cumdist': 'cum_dist',
+ 'check_factor': 'check_factor',
}
@@ -1599,6 +1600,10 @@ def read_data(columns, ids=[], doclean=True, workstrokesonly=True, debug=False,
try:
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):
data = [
df.select(columns)
@@ -1619,21 +1624,24 @@ def read_data(columns, ids=[], doclean=True, workstrokesonly=True, debug=False,
stringcolumns.append(c)
else:
intcolumns.append(c)
-
- data = [
- df.with_columns(
- cs.float().cast(pl.Float64)
- ).with_columns(
- cs.integer().cast(pl.Int64)
- ).with_columns(
- cs.by_name(intcolumns).cast(pl.Int64)
- ).with_columns(
- cs.by_name(floatcolumns).cast(pl.Float64)
- ).with_columns(
- cs.by_name(stringcolumns).cast(pl.String)
- )
- for df in data
- ]
+
+ try:
+ data = [
+ df.with_columns(
+ cs.float().cast(pl.Float64)
+ ).with_columns(
+ cs.integer().cast(pl.Int64)
+ ).with_columns(
+ cs.by_name(intcolumns).cast(pl.Int64)
+ ).with_columns(
+ cs.by_name(floatcolumns).cast(pl.Float64)
+ ).with_columns(
+ cs.by_name(stringcolumns).cast(pl.String)
+ )
+ for df in data
+ ]
+ except ComputeError:
+ pass
try:
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)'],
)
+
+ 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:
data = data.with_columns(
wash = pl.lit(0.0),
diff --git a/rowers/forms.py b/rowers/forms.py
index 66d723c0..fa3f1b4b 100644
--- a/rowers/forms.py
+++ b/rowers/forms.py
@@ -1478,15 +1478,18 @@ class FusionMetricChoiceForm(ModelForm):
value in self.fields['columns'].choices}
for label in labeldict:
- if df.loc[:, label].std() == 0:
- try:
- formaxlabels2.pop(label)
- except KeyError: # pragma: no cover
- pass
+ try:
+ if df.loc[:, label].std() == 0:
+ try:
+ formaxlabels2.pop(label)
+ except KeyError: # pragma: no cover
+ pass
+ except KeyError: # pragma: no cover
+ formaxlabels2.pop(label)
- metricchoices = list(
- sorted(formaxlabels2.items(), key=lambda x: x[1]))
- self.fields['columns'].choices = metricchoices
+ metricchoices = list(
+ sorted(formaxlabels2.items(), key=lambda x: x[1]))
+ self.fields['columns'].choices = metricchoices
class PlannedSessionSelectForm(forms.Form):
diff --git a/rowers/interactiveplots.py b/rowers/interactiveplots.py
index 2995162a..4e4d4c74 100644
--- a/rowers/interactiveplots.py
+++ b/rowers/interactiveplots.py
@@ -1841,6 +1841,8 @@ def interactive_flex_chart2(id, r, promember=0,
rowdata[column], 5))
except KeyError:
pass
+ except ColumnNotFoundError:
+ pass
if len(rowdata) < 2:
if promember:
@@ -1938,10 +1940,10 @@ def interactive_flex_chart2(id, r, promember=0,
rowdata = rowdata.with_columns((pl.lit(axlabels[yparam2])).alias("yname2"))
except (KeyError, ColumnNotFoundError): # pragma: no cover
rowdata = rowdata.with_columns((pl.lit(yparam2)).alias("yname2"))
-
else: # pragma: no cover
rowdata = rowdata.with_columns((pl.col("yname1")).alias("yname2"))
+
def func(x, a, b):
return a*x+b
diff --git a/rowers/metrics.py b/rowers/metrics.py
index 83299c9c..832453aa 100644
--- a/rowers/metrics.py
+++ b/rowers/metrics.py
@@ -1,7 +1,7 @@
from rowers.utils import lbstoN
import numpy as np
-
+import yaml
import pandas as pd
from scipy import optimize
from django.utils import timezone
@@ -44,322 +44,16 @@ nometrics = [
'cum_dist.1'
]
-rowingmetrics = (
- ('time', {
- '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'}),
+with open("rowingmetrics.yaml", "r") as file:
+ td = yaml.safe_load(file)
+rowingmetrics = tuple(
+ (key, value)
+ for key, value in td.items()
)
+
+
metricsdicts = {}
for key, dict in rowingmetrics:
metricsdicts[key] = dict
diff --git a/rowers/models.py b/rowers/models.py
index bd99bb13..857a2680 100644
--- a/rowers/models.py
+++ b/rowers/models.py
@@ -27,7 +27,7 @@ from django.contrib.admin.widgets import AdminDateWidget, AdminTimeWidget, Admin
import os
import json
import ssl
-
+import secrets
import re
import pytz
@@ -923,7 +923,18 @@ class CoachingGroup(models.Model):
return Rower.objects.filter(mycoachgroup=self)
# 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):
adaptivetypes = mytypes.adaptivetypes
diff --git a/rowers/plannedsessions.py b/rowers/plannedsessions.py
index c8ca0ab2..d9e6add5 100644
--- a/rowers/plannedsessions.py
+++ b/rowers/plannedsessions.py
@@ -888,6 +888,10 @@ def get_dates_timeperiod(request, startdatestring='', enddatestring='',
startdate = timezone.now()-timezone.timedelta(days=5)
startdate = startdate.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:
e = startdate
diff --git a/rowers/templates/rower_exportsettings.html b/rowers/templates/rower_exportsettings.html
index 5d1180f8..4990ce37 100644
--- a/rowers/templates/rower_exportsettings.html
+++ b/rowers/templates/rower_exportsettings.html
@@ -7,37 +7,38 @@
{% block main %}
Import and Export Settings for {{ rower.user.first_name }} {{ rower.user.last_name }}
-You are currently connected to:
- {% if rower.c2token is not None and rower.c2token != '' %}
- Concept2 Logbook,
- {% endif %}
- {% if rower.nktoken is not None and rower.nktoken != '' %}
- NK Logbook,
- {% endif %}
- {% if rower.sporttrackstoken is not None and rower.sporttrackstoken != '' %}
- SportTracks,
- {% endif %}
- {% if rower.tptoken is not None and rower.tptoken != '' %}
- TrainingPeaks,
- {% endif %}
- {% if rower.polartoken is not None and rower.polartoken != '' %}
- Polar,
- {% endif %}
- {% if rower.garmintoken is not None and rower.garmintoken != '' %}
- Garmin Connect,
- {% endif %}
- {% if rower.stravatoken is not None and rower.stravatoken != '' %}
- Strava,
- {% endif %}
- {% if rower.rp3token is not None and rower.rp3token != '' %}
- RP3
- {% endif %}
- {% if rower.rojabo_token is not None and rower.rojabo_token != '' %}
- Rojabo
- {% endif %}
-
-
-
+
+ -
+
You are currently connected to:
+ {% if rower.c2token is not None and rower.c2token != '' %}
+ Concept2 Logbook,
+ {% endif %}
+ {% if rower.nktoken is not None and rower.nktoken != '' %}
+ NK Logbook,
+ {% endif %}
+ {% if rower.sporttrackstoken is not None and rower.sporttrackstoken != '' %}
+ SportTracks,
+ {% endif %}
+ {% if rower.tptoken is not None and rower.tptoken != '' %}
+ TrainingPeaks,
+ {% endif %}
+ {% if rower.polartoken is not None and rower.polartoken != '' %}
+ Polar,
+ {% endif %}
+ {% if rower.garmintoken is not None and rower.garmintoken != '' %}
+ Garmin Connect,
+ {% endif %}
+ {% if rower.stravatoken is not None and rower.stravatoken != '' %}
+ Strava,
+ {% endif %}
+ {% if rower.rp3token is not None and rower.rp3token != '' %}
+ RP3
+ {% endif %}
+ {% if rower.rojabo_token is not None and rower.rojabo_token != '' %}
+ Rojabo
+ {% endif %}
+
+
{% if form.errors %}
Please correct the error{{ form.errors|pluralize }} below.
@@ -71,28 +72,57 @@
a workout on Strava. If you want Deletions to propagate to Rowsandall, tick the Strava Auto Delete
check box.
-Click on one of the icons below to connect to the service of your
- choice or to renew the authorization.
-
-
-
-
-
+ -
+ {% if grants %}
+
Applications
+
+
+
+ | Application |
+ Scope |
+ Revoke |
+
+
+
+ {% for grant in grants %}
+
+ | {{ grant.application }} |
+ {{ grant.scope }} |
+
+ Revoke
+ |
+
+ {% endfor %}
+
+
+ {% endif %}
+ API Key
+ {{ apikey }}
+
+ Regenerate
+
+ 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.
+
+
+
+ Click on one of the icons below to connect to the service of your
+ choice or to renew the authorization.
+ 
+ 
+ 
+ 
+ 
-
-
-
-
- 
-
-{% if user.is_staff %}
-iDoklad authorize
-{% endif %}
-
+ 
+
+ 
+ 
+ 
{% endblock %}
diff --git a/rowers/templates/rower_form.html b/rowers/templates/rower_form.html
index 68096f27..4912dd0e 100644
--- a/rowers/templates/rower_form.html
+++ b/rowers/templates/rower_form.html
@@ -116,6 +116,14 @@
{% endif %}
+
+ API Key
+ {{ apikey }}
+
+ Regenerate
+
+ 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.
+
{% endif %}
diff --git a/rowers/tests/test_api.py b/rowers/tests/test_api.py
index 1187265e..74dff25d 100644
--- a/rowers/tests/test_api.py
+++ b/rowers/tests/test_api.py
@@ -26,6 +26,7 @@ import json
from rowers.ownapistuff import *
from rowers.views.apiviews import *
+from rowers.models import APIKey
class OwnApi(TestCase):
def setUp(self):
@@ -44,9 +45,8 @@ class OwnApi(TestCase):
self.password = faker.word()
self.u.set_password(self.password)
self.u.save()
-
self.factory = APIRequestFactory()
-
+ self.apikey = APIKey.objects.create(user=self.u)
def test_strokedataform(self):
login = self.c.login(username=self.u.username, password=self.password)
@@ -210,6 +210,75 @@ class OwnApi(TestCase):
response = strokedata_tcx(request)
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):
login = self.c.login(username=self.u.username, password=self.password)
self.assertTrue(login)
diff --git a/rowers/urls.py b/rowers/urls.py
index 5691b5c3..8701ff9c 100644
--- a/rowers/urls.py
+++ b/rowers/urls.py
@@ -254,6 +254,10 @@ urlpatterns = [
name='strokedatajson_v3'),
re_path(r'^api/TCX/workouts/$', views.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/(?P\d+)/$', views.get_crewnerd_kml, name='get_crewnerd_kml'),
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,
name='manager_requests_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\d+)/$',
views.rower_edit_view, name='rower_edit_view'),
re_path(r'^me/preferences/$', views.rower_prefs_view,
diff --git a/rowers/views/analysisviews.py b/rowers/views/analysisviews.py
index 1938df26..c6401e09 100644
--- a/rowers/views/analysisviews.py
+++ b/rowers/views/analysisviews.py
@@ -2282,11 +2282,11 @@ def history_view_data(request, userid=0):
try:
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
try:
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['powermean'] = int(wavg(ddf, 'power', 'deltat'))
diff --git a/rowers/views/apiviews.py b/rowers/views/apiviews.py
index 8c214655..b84dc6f5 100644
--- a/rowers/views/apiviews.py
+++ b/rowers/views/apiviews.py
@@ -2,7 +2,8 @@ from rowers.views.statements import *
from rowers.tasks import handle_calctrimp
from rowers.opaque import encoder
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 pendulum
@@ -21,6 +22,8 @@ import rowingdata.tcxtools as tcxtools
from rowingdata import TCXParser, rowingdata
import arrow
+import base64
+
class XMLParser(BaseParser):
media_type = "application/xml"
@@ -28,6 +31,8 @@ class XMLParser(BaseParser):
dologging("apilog.log", "XML Parser")
try:
s = ET.parse(stream).getroot()
+ except ET.XMLSyntaxError:
+ return HttpResponse(status=400)
except Exception as e: # pragma: no cover
dologging("apilog.log",e)
return HttpResponse(status=500)
@@ -422,6 +427,110 @@ def get_crewnerd_liked(request):
# 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
#@login_required()
@api_view(["POST"])
diff --git a/rowers/views/statements.py b/rowers/views/statements.py
index d4b675f0..01771ce6 100644
--- a/rowers/views/statements.py
+++ b/rowers/views/statements.py
@@ -93,7 +93,8 @@ from django.http import (
HttpResponse, HttpResponseRedirect,
JsonResponse,
HttpResponseForbidden, HttpResponseNotAllowed,
- HttpResponseNotFound, Http404
+ HttpResponseNotFound, Http404,
+ HttpResponseBadRequest,
)
from django.contrib.auth import authenticate, login, logout
from rowers.forms import (
@@ -163,7 +164,7 @@ from rowers.models import (
StandardCollection, CourseStandard,
VirtualRaceFollower, TombStone, InstantPlan,
PlannedSessionStep,InStrokeAnalysis, ForceCurveAnalysis, SyncRecord,
- UserMessage,
+ UserMessage,APIKey,
)
from rowers.models import ( RowerPowerForm, RowerHRZonesForm, SimpleRowerPowerForm,
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
+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
queue = django_rq.get_queue('default')
queuelow = django_rq.get_queue('low')
diff --git a/rowers/views/userviews.py b/rowers/views/userviews.py
index 0c1eed43..629be7a2 100644
--- a/rowers/views/userviews.py
+++ b/rowers/views/userviews.py
@@ -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',
{'form': form,
'rower': r,
'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)
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',
{
'teams': get_my_teams(request.user),
@@ -640,8 +655,24 @@ def rower_edit_view(request, rowerid=0, userid=0, message=""):
'userform': userform,
'accountform': accountform,
'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
@login_required()
@permission_required('rower.is_coach', fn=get_user_by_userid, raise_exception=True)
diff --git a/rowers/views/workoutviews.py b/rowers/views/workoutviews.py
index eb0aed30..a250e4aa 100644
--- a/rowers/views/workoutviews.py
+++ b/rowers/views/workoutviews.py
@@ -4931,7 +4931,6 @@ def workout_upload_api(request):
post_data = {k: q.getlist(k) if len(
q.getlist(k)) > 1 else v for k, v in q.items()}
-
# only allow local host
hostt = request.get_host().split(':')
if hostt[0] not in ['localhost', '127.0.0.1', 'dev.rowsandall.com', 'rowsandall.com']:
diff --git a/rowers/weather.py b/rowers/weather.py
index c6533127..e7f2e0eb 100644
--- a/rowers/weather.py
+++ b/rowers/weather.py
@@ -2,7 +2,8 @@ import requests
from requests.exceptions import ConnectionError
import json
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
from datetime import datetime
from rowingdata import rowingdata, geo_distance
diff --git a/rowingmetrics.yaml b/rowingmetrics.yaml
new file mode 100644
index 00000000..73957883
--- /dev/null
+++ b/rowingmetrics.yaml
@@ -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
diff --git a/rowsandall_app/settings.py b/rowsandall_app/settings.py
index 7cbfe5dc..28f52fca 100644
--- a/rowsandall_app/settings.py
+++ b/rowsandall_app/settings.py
@@ -483,10 +483,11 @@ REST_FRAMEWORK = {
# 'rest_framework.permissions.DjangoModelPermissions'
],
'DEFAULT_AUTHENTICATION_CLASSES': (
- 'rest_framework.authentication.BasicAuthentication',
+ #'rest_framework.authentication.BasicAuthentication',
# 'rest_framework.authentication.SessionAuthentication',
# 'rest_framework.authentication.TokenAuthentication',
'oauth2_provider.contrib.rest_framework.OAuth2Authentication',
+ 'rowers.authentication.APIKeyAuthentication',
),
'PAGE_SIZE': 20,
'DEFAULT_PAGINATION_CLASS': 'rest_framework.pagination.LimitOffsetPagination',