From 0cd8fabc13f5c30f176eb1fb05750e7c5eee7526 Mon Sep 17 00:00:00 2001 From: Sander Roosendaal Date: Tue, 30 Mar 2021 09:05:26 +0200 Subject: [PATCH] passing tests with mock server for nk workouts list --- rowers/nkstuff.py | 107 ++++++++++++++++++++++++ rowers/stravastuff.py | 2 - rowers/templates/nk_list_import.html | 58 +++++++++++++ rowers/tests/mocks.py | 21 +++++ rowers/tests/test_imports.py | 64 ++++++++++++++ rowers/tests/testdata/nkworkoutlist.txt | 84 +++++++++++++++++++ rowers/tests/testdata/nkworkouts.txt | 84 +++++++++++++++++++ rowers/urls.py | 1 + rowers/views/importviews.py | 102 +++++++++++++++++++++- rowers/views/statements.py | 4 +- rowsandall_app/settings.py | 3 +- 11 files changed, 523 insertions(+), 7 deletions(-) create mode 100644 rowers/nkstuff.py create mode 100644 rowers/templates/nk_list_import.html create mode 100644 rowers/tests/testdata/nkworkoutlist.txt create mode 100644 rowers/tests/testdata/nkworkouts.txt diff --git a/rowers/nkstuff.py b/rowers/nkstuff.py new file mode 100644 index 00000000..f5dbfb61 --- /dev/null +++ b/rowers/nkstuff.py @@ -0,0 +1,107 @@ +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function +from __future__ import unicode_literals +from __future__ import unicode_literals, absolute_import + +import time +from time import strftime + +import requests + +#https://oauth-stage.nkrowlink.com/oauth/authorizegrant_type=authorization_code&response_type=code&client_id=rowsandall-staging&scope=read&state=fc8fc3d8-ce0a-443e-838a-1c06fb5317c6&redirect_uri=https%3A%2F%2Fdunav.ngrok.io%2Fnk_callback%2F +#https://oauth-stage.nkrowlink.com/oauth/authorize?grant_type=authorization_code&response_type=code&client_id=rowsandall-staging&scope=read&state=1234&redirect_uri=https%3A%2F%2Fdev.rowsandall.com%2Fnk_callback + +import django_rq +queue = django_rq.get_queue('default') +queuelow = django_rq.get_queue('low') +queuehigh = django_rq.get_queue('low') + +from rowers.rower_rules import is_workout_user, ispromember + +from iso8601 import ParseError +from rowers.utils import myqueue + +import rowers.mytypes as mytypes +import gzip + +from rowsandall_app.settings import ( + NK_CLIENT_ID, NK_REDIRECT_URI, NK_CLIENT_SECRET, + SITE_URL, NK_API_LOCATION + ) + +try: + from json.decoder import JSONDecodeError +except ImportError: + JSONDecodeError = ValueError + +from rowers.imports import * + +oauth_data = { + 'client_id': NK_CLIENT_ID, + 'client_secret': NK_CLIENT_SECRET, + 'redirect_uri': NK_REDIRECT_URI, + 'autorization_uri': "https://oauth-stage.nkrowlink.com/oauth/authorize", + 'content_type': 'application/json', + 'tokenname': 'nktoken', + 'refreshtokenname': 'nkrefreshtoken', + 'expirydatename': 'nktokenexpirydate', + 'bearer_auth': True, + 'base_url': "https://oauth-stage.nkrowlink.com/oauth/token", + 'grant_type': 'refresh_token', + 'scope':'read', + } + +def get_token(code): + return imports_get_token(code, oauth_data) + +def nk_open(user): + t = time.localtime() + timestamp = time.strftime('%b-%d-%Y_%H%M', t) + token = imports_open(user,oauth_data) + return token + +def do_refresh_token(refreshtoken): + return imports_do_refresh_token(refreshtoken, oauth_data) + +def rower_nk_token_refresh(user): + r = Rower.objects.get(user=user) + res = do_refresh_token(r.nkrefreshtoken) + access_token = res[0] + expires_in = res[1] + refresh_token = res[2] + expirydatetime = timezone.now()+timedelta(seconds=expires_in) + + r.nktoken = access_token + r.nktokenexpirydate = expirydatetime + r.nkrefreshtoken = refresh_token + r.save() + + return r.nktoken + +def make_authorization_url(request): + return imports_make_authorization_url(oauth_data) + +def get_nk_workout_list(user,fake=False): + r = Rower.objects.get(user=user) + + if (r.nktoken == '') or (r.nktoken is None): + s = "Token doesn't exist. Need to authorize" + return custom_exception_handler(401,s) + elif (r.nktokenexpirydate is None or timezone.now()+timedelta(seconds=3599)>r.nktokenexpirydate): + s = "Token expired. Needs to refresh." + return custom_exception_handler(401,s) + else: + # ready to fetch. Hurray + authorizationstring = str('Bearer ' + r.nktoken) + headers = {'Authorization': authorizationstring, + 'user-agent': 'sanderroosendaal', + 'Content-Type': 'application/json'} + + url = NK_API_LOCATION+"api/v1/sessions" + + params = {} # start / end time + + s = requests.get(url,headers=headers,params=params) + + return s diff --git a/rowers/stravastuff.py b/rowers/stravastuff.py index 2cef3373..d0914b77 100644 --- a/rowers/stravastuff.py +++ b/rowers/stravastuff.py @@ -131,8 +131,6 @@ def strava_establish_push(): response = requests.post(url,data=post_data) - print(response.json()) - return response.status_code def strava_list_push(): diff --git a/rowers/templates/nk_list_import.html b/rowers/templates/nk_list_import.html new file mode 100644 index 00000000..44ae0554 --- /dev/null +++ b/rowers/templates/nk_list_import.html @@ -0,0 +1,58 @@ +{% extends "newbase.html" %} +{% load staticfiles %} +{% load rowerfilters %} + +{% block title %}Workouts{% endblock %} + +{% block main %} +

Available on nk

+{% if workouts %} +
    +
  • + Import all NEW +
  • +
  • +

    This imports all workouts that have not been imported to rowsandall.com. + The action may take a longer time to process, so please be patient. Click on Import in the list below to import an individual workout. +

    +
  • + +
  • + + + + + + + + + + + + + {% for workout in workouts %} + + + + + + + + + + {% endfor %} + +
    Import Name Date Duration Distance New
    + Import{{ workout|lookup:'name' }}{{ workout|lookup:'starttime' }}{{ workout|lookup:'duration' }} {{ workout|lookup:'distance' }} m{{ workout|lookup:'new' }}
    +
  • +
+{% else %} +

+ No workouts found +

+ {% endif %} + {% endblock %} + +{% block sidebar %} +{% include 'menu_workouts.html' %} +{% endblock %} diff --git a/rowers/tests/mocks.py b/rowers/tests/mocks.py index 35adee66..79fe3065 100644 --- a/rowers/tests/mocks.py +++ b/rowers/tests/mocks.py @@ -621,6 +621,8 @@ def mocked_requests(*args, **kwargs): stravaworkoutlist = json.load(open('rowers/tests/testdata/stravaworkoutlist.txt')) sporttracksworkoutlist = json.load(open('rowers/tests/testdata/sporttracksworkouts.txt')) + nkworkoutlist = json.load(open('rowers/tests/testdata/nkworkouts.txt')) + rkworkoutlistjson = json.load(open('rowers/tests/testdata/rkworkoutslist.txt','r')) uaworkoutlistjson = json.load(open('rowers/tests/testdata/uaworkoutlist.txt','r')) @@ -732,6 +734,7 @@ def mocked_requests(*args, **kwargs): rktester = re.compile('.*?runkeeper\.com') uatester = re.compile('.*?mapmyfitness\.com') tptester = re.compile('.*?trainingpeaks\.com') + nktester = re.compile('.*?nkrowlink\.com') garmintester = re.compile('.*?garmin\.com') c2importregex = '.*?concept2.com\/api\/users\/me\/results\/\d+' @@ -746,6 +749,9 @@ def mocked_requests(*args, **kwargs): c2workoutlistregex = '.*?concept2\.com\/api\/users\/me\/results\?page=\d' c2workoutlisttester = re.compile(c2workoutlistregex) + nkworkoutlistregex = '.*?nkrowlink\.com\/api\/v1\/sessions' + nkworkoutlisttester = re.compile(nkworkoutlistregex) + stravaathleteregex = '.*?strava\.com\/api\/v3\/athlete$' stravaathletetester = re.compile(stravaathleteregex) @@ -796,6 +802,7 @@ def mocked_requests(*args, **kwargs): garmindownloadregex = '.*?garmin\.com\/mockfile?id=1' garmindownloadtester = re.compile(garmindownloadregex) + if garmintester.match(args[0]): if garmindownloadtester.match(args[0]): return MockStreamResponse('rowers/tests/testdata/3x250m.fit',200) @@ -910,6 +917,20 @@ def mocked_requests(*args, **kwargs): return MockResponse(sporttracksworkoutlist,200) + if nktester.match(args[0]): + if nkworkoutlisttester.match(args[0]): + return MockResponse(nkworkoutlist,200) + elif 'token' in args[0]: + json_data = { + "token_type": "Bearer", + "access_token": "987654321234567898765432123456789", + "refresh_token": "1234567898765432112345678987654321", + "expires_at": arrow.now().timestamp()+7200 + } + + return MockResponse(json_data,200) + + if stravatester.match(args[0]): if stravaworkoutlisttester.match(args[0]): return MockResponse(stravaworkoutlist,200) diff --git a/rowers/tests/test_imports.py b/rowers/tests/test_imports.py index a83d534e..9b0fb73c 100644 --- a/rowers/tests/test_imports.py +++ b/rowers/tests/test_imports.py @@ -360,6 +360,70 @@ class C2ObjectsTokenExpired(DjangoTestCase): self.assertEqual(response.status_code, 200) +#@pytest.mark.django_db +@override_settings(TESTING=True) +class NKObjects(DjangoTestCase): + def setUp(self): + self.c = Client() + self.u = User.objects.create_user('john', + 'sander@ds.ds', + 'koeinsloot') + + self.u.first_name = 'John' + self.u.last_name = 'Sander' + self.u.save() + self.r = Rower.objects.create(user=self.u,gdproptin=True,surveydone=True, + gdproptindate=timezone.now() + ) + + self.r.nktoken = '12' + self.r.nkrefreshtoken = '123' + self.r.nktokenexpirydate = datetime.datetime.now()-datetime.timedelta(days=1) + + self.r.save() + self.c.login(username='john',password='koeinsloot') + self.nu = datetime.datetime.now() + + filename = 'rowers/tests/testdata/testdata.csv' + + rr = rrower(hrmax=self.r.max,hrut2=self.r.ut2, + hrut1=self.r.ut1,hrat=self.r.at, + hrtr=self.r.tr,hran=self.r.an,ftp=self.r.ftp) + row = rdata(filename,rower=rr) + totaldist = row.df['cum_dist'].max() + totaltime = row.df['TimeStamp (sec)'].max()-row.df['TimeStamp (sec)'].min() + totaltime = totaltime+row.df.loc[:,' ElapsedTime (sec)'].iloc[0] + + + hours = int(totaltime/3600.) + minutes = int((totaltime - 3600.*hours)/60.) + seconds = int(totaltime - 3600.*hours - 60.*minutes) + tenths = int(10*(totaltime - 3600.*hours - 60.*minutes - seconds)) + + duration = "%s:%s:%s.%s" % (hours,minutes,seconds,tenths) + + + workoutdate = row.rowdatetime.strftime('%Y-%m-%d') + workoutstarttime = row.rowdatetime.strftime('%H:%M:%S') + + self.w = Workout.objects.create( + name='testworkout',workouttype='water', + user=self.r,date=self.nu.strftime('%Y-%m-%d'), + starttime=workoutstarttime, + startdatetime=row.rowdatetime, + duration=duration,distance=totaldist, + csvfilename=filename + ) + + @patch('rowers.nkstuff.requests.get', side_effect=mocked_requests) + @patch('rowers.nkstuff.requests.post', side_effect=mocked_requests) + def test_nk_list(self, mock_get, mockpost): + result = rowers.nkstuff.rower_nk_token_refresh(self.u) + self.assertEqual(result,"987654321234567898765432123456789") + response = self.c.get('/rowers/workout/nkimport/') + + self.assertEqual(response.status_code,200) + #@pytest.mark.django_db diff --git a/rowers/tests/testdata/nkworkoutlist.txt b/rowers/tests/testdata/nkworkoutlist.txt new file mode 100644 index 00000000..320a47cc --- /dev/null +++ b/rowers/tests/testdata/nkworkoutlist.txt @@ -0,0 +1,84 @@ +[ + { + "id": 1, + "name": "JustGo-7189M", + "type": 0, + "speedInput": 0, + "startTime": 1614264826, + "endTime": 1614269826, + "location": "Some Location", + "deviceId": 11, + "elapsedTime": 1140000, + "totalDistanceImp": 2920.69, + "totalDistanceGps": 2286.5, + "avgPaceImp": 195159.36302723, + "avgPaceGps": 250466.812537141, + "avgStrokeRate": 19.5, + "distStrokeImp": 8.51, + "distStrokeGps": 6.77, + "avgHeartRate": 158, + "totalStrokeCount": 343, + "totalCalories": 4959900, + "avgCalHour": 4482062.35252774, + "avgSpeedGps": 2, + "avgSpeedImp": 2.56, + "avgPower": 68.6979270660324, + "avgCatch": -46.7961491475831, + "avgSlip": 16.1616225246003, + "avgFinish": 48.1512290049444, + "avgWash": 22.9514686031976, + "avgForceAvg": 114.502194258813, + "avgWork": 210.785493336831, + "avgForceMax": 249.481436977143, + "avgMaxForceAngle": 4.75378911974861, + "startGpsLat": 39.7356346, + "startGpsLon": -75.5581928, + "intervals": [ + { + "id": 1, + "sessionId": 1, + "startTime": 1614264826, + "intervalNumber": 1, + "sessionStrokeStartIndex": 473, + "sessionStrokeEndIndex": 674, + "sessionStrokeCount": 91, + "elapsedTime": 1140000, + "totalDistanceImp": 2920.69, + "totalDistanceGps": 2286.5, + "avgPaceImp": 195159.36302723, + "avgPaceGps": 250466.812537141, + "avgStrokeRate": 19.5, + "distStrokeImp": 8.51, + "distStrokeGps": 6.77, + "avgHeartRate": 158, + "totalStrokeCount": 343, + "totalCalories": 4959900, + "avgCalHour": 4482062.35252774, + "avgSpeedGps": 2, + "avgSpeedImp": 2.56, + "avgPower": 68.6979270660324, + "avgCatch": -46.7961491475831, + "avgSlip": 16.1616225246003, + "avgFinish": 48.1512290049444, + "avgWash": 22.9514686031976, + "avgForceAvg": 114.502194258813, + "avgWork": 210.785493336831, + "avgForceMax": 249.481436977143, + "avgMaxForceAngle": 4.75378911974861, + "startGpsLat": 39.7356346, + "startGpsLon": -75.5581928 + } + ], + "oarlockSessions": [ + { + "id": 1, + "sessionId": 1, + "boatName": "Fast Boat", + "seatNumber": 3, + "portStarboard": 0, + "oarLength": 284, + "oarInboardLength": 85 + } + ] + } +] diff --git a/rowers/tests/testdata/nkworkouts.txt b/rowers/tests/testdata/nkworkouts.txt new file mode 100644 index 00000000..320a47cc --- /dev/null +++ b/rowers/tests/testdata/nkworkouts.txt @@ -0,0 +1,84 @@ +[ + { + "id": 1, + "name": "JustGo-7189M", + "type": 0, + "speedInput": 0, + "startTime": 1614264826, + "endTime": 1614269826, + "location": "Some Location", + "deviceId": 11, + "elapsedTime": 1140000, + "totalDistanceImp": 2920.69, + "totalDistanceGps": 2286.5, + "avgPaceImp": 195159.36302723, + "avgPaceGps": 250466.812537141, + "avgStrokeRate": 19.5, + "distStrokeImp": 8.51, + "distStrokeGps": 6.77, + "avgHeartRate": 158, + "totalStrokeCount": 343, + "totalCalories": 4959900, + "avgCalHour": 4482062.35252774, + "avgSpeedGps": 2, + "avgSpeedImp": 2.56, + "avgPower": 68.6979270660324, + "avgCatch": -46.7961491475831, + "avgSlip": 16.1616225246003, + "avgFinish": 48.1512290049444, + "avgWash": 22.9514686031976, + "avgForceAvg": 114.502194258813, + "avgWork": 210.785493336831, + "avgForceMax": 249.481436977143, + "avgMaxForceAngle": 4.75378911974861, + "startGpsLat": 39.7356346, + "startGpsLon": -75.5581928, + "intervals": [ + { + "id": 1, + "sessionId": 1, + "startTime": 1614264826, + "intervalNumber": 1, + "sessionStrokeStartIndex": 473, + "sessionStrokeEndIndex": 674, + "sessionStrokeCount": 91, + "elapsedTime": 1140000, + "totalDistanceImp": 2920.69, + "totalDistanceGps": 2286.5, + "avgPaceImp": 195159.36302723, + "avgPaceGps": 250466.812537141, + "avgStrokeRate": 19.5, + "distStrokeImp": 8.51, + "distStrokeGps": 6.77, + "avgHeartRate": 158, + "totalStrokeCount": 343, + "totalCalories": 4959900, + "avgCalHour": 4482062.35252774, + "avgSpeedGps": 2, + "avgSpeedImp": 2.56, + "avgPower": 68.6979270660324, + "avgCatch": -46.7961491475831, + "avgSlip": 16.1616225246003, + "avgFinish": 48.1512290049444, + "avgWash": 22.9514686031976, + "avgForceAvg": 114.502194258813, + "avgWork": 210.785493336831, + "avgForceMax": 249.481436977143, + "avgMaxForceAngle": 4.75378911974861, + "startGpsLat": 39.7356346, + "startGpsLon": -75.5581928 + } + ], + "oarlockSessions": [ + { + "id": 1, + "sessionId": 1, + "boatName": "Fast Boat", + "seatNumber": 3, + "portStarboard": 0, + "oarLength": 284, + "oarInboardLength": 85 + } + ] + } +] diff --git a/rowers/urls.py b/rowers/urls.py index e6d1fc04..977c80c9 100644 --- a/rowers/urls.py +++ b/rowers/urls.py @@ -567,6 +567,7 @@ urlpatterns = [ re_path(r'^workout/stravaimport/user/(?P\d+)/$',views.workout_stravaimport_view,name='workout_stravaimport_view'), re_path(r'^workout/c2import/all/$',views.workout_getc2workout_all,name='workout_getc2workout_all'), re_path(r'^workout/c2import/all/(?P\d+)/$',views.workout_getc2workout_all,name='workout_getc2workout_all'), + re_path(r'^workout/nkimport/$',views.workout_nkimport_view,name='workout_nkimport_view'), re_path(r'^workout/rp3import/(?P\d+)/$',views.workout_getrp3importview, name='workout_getrp3importview'), re_path(r'^workout/rp3import/all/$',views.workout_getrp3workout_all,name='workout_getrp3workout_all'), diff --git a/rowers/views/importviews.py b/rowers/views/importviews.py index e5bdbaba..7e1a86d7 100644 --- a/rowers/views/importviews.py +++ b/rowers/views/importviews.py @@ -411,6 +411,8 @@ def rower_nk_authorize(request): return HttpResponseRedirect(url) + + # Concept2 authorization @login_required() def rower_c2_authorize(request): @@ -679,7 +681,7 @@ def rower_process_callback(request): message = "The resource owner or authorization server denied the request" messages.error(request,message) - url = reverse('workouts_view') + url = reverse('rower_exportsettings_view') return HttpResponseRedirect(url) @@ -689,7 +691,7 @@ def rower_process_callback(request): message += ' Contact info@rowsandall.com if this behavior persists.' messages.error(request,message) - url = reverse('workouts_view') + url = reverse('rower_exportsettings_view') return HttpResponseRedirect(url) @@ -775,11 +777,105 @@ def rower_process_garmincallback(request): @login_required() def rower_process_nkcallback(request): # do stuff + try: + code = request.get['code'] + res = nkstuff.get_token['code'] + except MultiValueDictKeyError: + message = "The resource owner or authorization server denied the request" + messages.error(request,message) + url = reverse('rower_exportsettings_view') + return HttpResponseRedirect(url) + + access_token = res[0] + if access_token == 0: + message = res[1] + message += ' Contact support@rowsandall.com if this behavior persists' + messages.error(request,message) + + url = reverse('rower_exportsettings_view') + + return HttpResponseRedirect(url) + + expires_in = res[1] + refresh_token = res[2] + expirydatetime = timezone.now()+datetime.timedelta(seconds=expires_in) + + r = getrower(request.user) + r.nktoken = access_token + r.nktokenexpirydate = expirydatetime + r.nkrefreshtoken = refresh_token + + r.save() + + successmessage = "Tokens stored. Good to go. Please check your import/export settings" + messages.info(request,successmessage) url = reverse('rower_exportsettings_view') - return HttpResponseRedirect(url) +@login_required() +@permission_required('rower.is_coach',fn=get_user_by_userid, raise_exception=True) +def workout_nkimport_view(request,userid=0): + r = getrequestrower(request,userid=userid) + try: + thetoken = nk_open(request.user) + except NoTokenError: + return HttpResponseRedirect("/rowers/me/stravaauthorize/") + + res = nkstuff.get_nk_workout_list(request.user) + + if (res.status_code != 200): + if (res.status_code == 401): + r = getrower(request.user) + if (r.stravatoken == '') or (r.stravatoken is None): + s = "Token doesn't exist. Need to authorize" + return HttpResponseRedirect("/rowers/me/stravaauthorize/") + message = "Something went wrong in workout_stravaimport_view" + messages.error(request,message) + url = reverse('workouts_view') + return HttpResponseRedirect(url) + + # get NK IDs + + nkdata = [{ + 'id':int(item['id']), + 'elapsed_time':item['elapsedTime'], + 'start_date':item['startTime'], + } for item in res.json()] + + workouts = [] + + for item in res.json(): + d = int(float(item['totalDistanceGps'])) # could also be Impeller + i = item['id'] + n = item['name'] + nnn = '' + ttot = str(datetime.timedelta(seconds=int(float(item['elapsedTime'])/1000.))) + s = item['startTime'] + keys = ['id','distance','duration','starttime','name','new'] + values = [i,d,ttot,s,n,nnn] + res = dict(zip(keys, values)) + workouts.append(res) + + breadcrumbs = [ + { + 'url':'/rowers/list-workouts/', + 'name':'Workouts' + }, + { + 'url':reverse('workout_nkimport_view'), + 'name':'Strava' + }, + ] + + return render(request,'nk_list_import.html', + { + 'workouts':workouts, + 'rower':r, + 'active':'nav-workouts', + 'breadcrumbs':breadcrumbs, + 'teams':get_my_teams(request.user) + }) # Process Strava Callback @login_required() diff --git a/rowers/views/statements.py b/rowers/views/statements.py index 88b7f36b..b323beb1 100644 --- a/rowers/views/statements.py +++ b/rowers/views/statements.py @@ -154,7 +154,9 @@ import os,sys import datetime import iso8601 import rowers.c2stuff as c2stuff +import rowers.nkstuff as nkstuff from rowers.c2stuff import c2_open +from rowers.nkstuff import nk_open from rowers.rp3stuff import rp3_open from rowers.runkeeperstuff import runkeeper_open from rowers.sporttracksstuff import sporttracks_open @@ -186,7 +188,7 @@ from rowsandall_app.settings import ( BRAINTREE_MERCHANT_ID,BRAINTREE_PUBLIC_KEY,BRAINTREE_PRIVATE_KEY, PAYMENT_PROCESSING_ON, RECAPTCHA_SITE_KEY, RECAPTCHA_SITE_SECRET, - NK_REDIRECT_URI, NK_ClIENT_ID, NK_CLIENT_SECRET + NK_REDIRECT_URI, NK_CLIENT_ID, NK_CLIENT_SECRET ) #from rowers.tasks_standalone import addcomment2 diff --git a/rowsandall_app/settings.py b/rowsandall_app/settings.py index 7fd1c457..f6c45d9d 100644 --- a/rowsandall_app/settings.py +++ b/rowsandall_app/settings.py @@ -329,9 +329,10 @@ RP3_REDIRECT_URI = CFG["rp3_redict_uri"] RP3_CLIENT_KEY = RP3_CLIENT_ID # Nielsen-Kellerman -NK_ClIENT_ID = CFG["nk_client_id"] +NK_CLIENT_ID = CFG["nk_client_id"] NK_CLIENT_SECRET = CFG["nk_client_secret"] NK_REDIRECT_URI = CFG["nk_redirect_uri"] +NK_API_LOCATION = CFG["nk_api_location"] # Full Site URL SITE_URL = CFG['site_url']