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.

    -

    connect with strava

    -

    connect with Concept2

    -

    connect with NK Logbook

    -

    connect with SportTracks

    -

    +

  • + {% if grants %} +

    Applications

    + + + + + + + + + + {% for grant in grants %} + + + + + + {% endfor %} + +
    ApplicationScopeRevoke
    {{ grant.application }}{{ grant.scope }} + Revoke +
    + {% 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.

+

connect with strava

+

connect with Concept2

+

connect with NK Logbook

+

connect with SportTracks

+

connect with Polar

-

connect with Polar

- -

connect with Garmin

-

connect with RP3

-

connect with Rojabo

- -{% if user.is_staff %} -

iDoklad authorize

-{% endif %} - +

connect with Polar

+ +

connect with Garmin

+

connect with RP3

+

connect with Rojabo

{% 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',