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/models.py b/rowers/models.py
index 5ddc51dc..310b93a4 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/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 6f32ba8d..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):
@@ -45,7 +46,7 @@ class OwnApi(TestCase):
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)
@@ -245,6 +246,38 @@ class OwnApi(TestCase):
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)
diff --git a/rowers/urls.py b/rowers/urls.py
index 8f82d46e..1e4210dc 100644
--- a/rowers/urls.py
+++ b/rowers/urls.py
@@ -256,6 +256,8 @@ urlpatterns = [
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'),
@@ -735,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/apiviews.py b/rowers/views/apiviews.py
index 9008a763..828216e8 100644
--- a/rowers/views/apiviews.py
+++ b/rowers/views/apiviews.py
@@ -473,6 +473,58 @@ def strokedata_rowingdata(request):
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 4807ebb5..01b768c5 100644
--- a/rowers/views/statements.py
+++ b/rowers/views/statements.py
@@ -164,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,
@@ -307,6 +307,27 @@ 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):
@@ -348,6 +369,15 @@ def view_or_basicauth(view, request, test_func, realm = "", *args, **kwargs):
#############################################################################
#
+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
diff --git a/rowers/views/userviews.py b/rowers/views/userviews.py
index 0c1eed43..250a5432 100644
--- a/rowers/views/userviews.py
+++ b/rowers/views/userviews.py
@@ -632,6 +632,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 +646,20 @@ 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:
+ apikey = APIKey.objects.get(user=request.user)
+ except APIKey.DoesNotExist:
+ apikey = APIKey.objects.create(user=request.user)
+
+ apikey.regenerate_key()
+
+ return HttpResponseRedirect(reverse('rower_edit_view'))
+
#simple initial settings page
@login_required()
@permission_required('rower.is_coach', fn=get_user_by_userid, raise_exception=True)
diff --git a/rowsandall_app/settings.py b/rowsandall_app/settings.py
index 8c1d4255..8d862009 100644
--- a/rowsandall_app/settings.py
+++ b/rowsandall_app/settings.py
@@ -485,6 +485,7 @@ REST_FRAMEWORK = {
# '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',