├── .babelrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── NOTICE ├── README.md ├── Vagrantfile ├── bootstrap.sh ├── credentialstore_keygen.py ├── email_cron.py ├── manage.py ├── package-lock.json ├── package.json ├── ratelimit_cron.py ├── requirements.txt ├── rollback_worker.py ├── runtests.py ├── stats_cron.py ├── sync_cpulimit.py ├── sync_global_watchdog.py ├── sync_poll_triggers.py ├── sync_remote_triggers.py ├── sync_scheduler.py ├── sync_watchdog.py ├── sync_worker.py ├── tapiriik ├── __init__.py ├── auth │ ├── __init__.py │ ├── credential_storage.py │ └── totp.py ├── database │ ├── __init__.py │ └── tz.py ├── frontend │ ├── app.jsx │ └── components │ │ ├── aerobiaConfig.jsx │ │ ├── csrftoken.jsx │ │ ├── empty.jsx │ │ ├── exportConfig.jsx │ │ ├── localExporter │ │ └── localExporterConfig.jsx │ │ ├── ruleLine.jsx │ │ └── ruleList.jsx ├── local_settings.py.example ├── locale │ ├── fr │ │ └── LC_MESSAGES │ │ │ ├── django.mo │ │ │ └── django.po │ └── ru │ │ └── LC_MESSAGES │ │ ├── django.mo │ │ └── django.po ├── messagequeue │ └── __init__.py ├── payments │ ├── __init__.py │ ├── external │ │ ├── __init__.py │ │ ├── motivato.py │ │ └── provider_base.py │ └── payments.py ├── requests_lib.py ├── services │ ├── Aerobia │ │ ├── __init__.py │ │ └── aerobia.py │ ├── BeginnerTriathlete │ │ ├── __init__.py │ │ └── beginnertriathlete.py │ ├── DecathlonCoach │ │ ├── __init__.py │ │ └── decathloncoach.py │ ├── Dropbox │ │ ├── __init__.py │ │ └── dropbox.py │ ├── Endomondo │ │ ├── __init__.py │ │ └── endomondo.py │ ├── GarminConnect │ │ ├── __init__.py │ │ └── garminconnect.py │ ├── LocalExporter │ │ ├── __init__.py │ │ └── localExporter.py │ ├── Motivato │ │ ├── __init__.py │ │ └── motivato.py │ ├── NikePlus │ │ ├── __init__.py │ │ └── nikeplus.py │ ├── PolarFlow │ │ ├── __init__.py │ │ └── polarflow.py │ ├── PolarPersonalTrainer │ │ ├── __init__.py │ │ ├── polarpersonaltrainer.py │ │ └── pptToTcx.py │ ├── Pulsstory │ │ ├── __init__.py │ │ └── pulsstory.py │ ├── RideWithGPS │ │ ├── __init__.py │ │ └── rwgps.py │ ├── RunKeeper │ │ ├── __init__.py │ │ └── runkeeper.py │ ├── Setio │ │ ├── __init__.py │ │ └── setio.py │ ├── Singletracker │ │ ├── __init__.py │ │ └── singletracker.py │ ├── Smashrun │ │ ├── __init__.py │ │ └── smashrun.py │ ├── SportTracks │ │ ├── __init__.py │ │ └── sporttracks.py │ ├── Strava │ │ ├── __init__.py │ │ └── strava.py │ ├── TrainAsONE │ │ ├── __init__.py │ │ └── trainasone.py │ ├── TrainerRoad │ │ ├── __init__.py │ │ └── trainerroad.py │ ├── TrainingPeaks │ │ ├── __init__.py │ │ └── trainingpeaks.py │ ├── VeloHero │ │ ├── __init__.py │ │ └── velohero.py │ ├── __init__.py │ ├── api.py │ ├── auto_pause.py │ ├── devices.py │ ├── exception_tools.py │ ├── fit.py │ ├── gpx.py │ ├── interchange.py │ ├── pwx.py │ ├── ratelimiting.py │ ├── rollback.py │ ├── service.py │ ├── service_base.py │ ├── service_record.py │ ├── sessioncache.py │ ├── statistic_calculator.py │ ├── stream_sampling.py │ └── tcx.py ├── settings.py ├── sync │ ├── __init__.py │ ├── activity_record.py │ └── sync.py ├── testing │ ├── __init__.py │ ├── data │ │ ├── garmin_parse_1.tcx │ │ └── test1.tcx │ ├── gpx.py │ ├── interchange.py │ ├── statistics.py │ ├── sync.py │ ├── tcx.py │ └── testtools.py ├── urls.py ├── web │ ├── __init__.py │ ├── context_processors.py │ ├── email.py │ ├── models.py │ ├── startup.py │ ├── static │ │ ├── css │ │ │ ├── diagnostics.css │ │ │ └── style.css │ │ ├── errors │ │ │ └── upgrade.html │ │ ├── img │ │ │ ├── android-chrome-192x192.png │ │ │ ├── android-chrome-512x512.png │ │ │ ├── apple-touch-icon.png │ │ │ ├── art │ │ │ │ └── mapmyfitness-logo.ai │ │ │ ├── bg-sunset.svg │ │ │ ├── connector-dot.png │ │ │ ├── email │ │ │ │ ├── corner_bl.gif │ │ │ │ ├── corner_br.gif │ │ │ │ ├── corner_tl.gif │ │ │ │ ├── corner_tr.gif │ │ │ │ ├── tapiriik-wordmark.gif │ │ │ │ └── trans.gif │ │ │ ├── favicon-16x16.png │ │ │ ├── favicon-32x32.png │ │ │ ├── favicon.ico │ │ │ ├── favicon.png │ │ │ ├── garminconnect_useradd.png │ │ │ ├── link.png │ │ │ ├── mstile-150x150.png │ │ │ ├── pp-logo.png │ │ │ ├── privacy-cached.png │ │ │ ├── privacy-no.png │ │ │ ├── privacy-optin.png │ │ │ ├── privacy-yes.png │ │ │ ├── rule-add.svg │ │ │ ├── rule-delete.svg │ │ │ ├── safari-pinned-tab.svg │ │ │ ├── services │ │ │ │ ├── aerobia.png │ │ │ │ ├── aerobia_l.png │ │ │ │ ├── beginnertriathlete.png │ │ │ │ ├── beginnertriathlete_l.png │ │ │ │ ├── decathloncoach.png │ │ │ │ ├── decathloncoach_l.png │ │ │ │ ├── dropbox.png │ │ │ │ ├── dropbox_l.png │ │ │ │ ├── endomondo.png │ │ │ │ ├── endomondo_l.png │ │ │ │ ├── garminconnect.png │ │ │ │ ├── garminconnect_l.png │ │ │ │ ├── localexporter.png │ │ │ │ ├── localexporter_l.png │ │ │ │ ├── motivato.png │ │ │ │ ├── motivato_l.png │ │ │ │ ├── nikeplus.png │ │ │ │ ├── nikeplus_l.png │ │ │ │ ├── polarflow.png │ │ │ │ ├── polarflow_l.png │ │ │ │ ├── polarpersonaltrainer.png │ │ │ │ ├── polarpersonaltrainer_l.png │ │ │ │ ├── pulsstory.png │ │ │ │ ├── pulsstory_l.png │ │ │ │ ├── runkeeper.png │ │ │ │ ├── runkeeper_l.png │ │ │ │ ├── runsense.png │ │ │ │ ├── runsense_l.png │ │ │ │ ├── rwgps.png │ │ │ │ ├── rwgps_l.png │ │ │ │ ├── setio.png │ │ │ │ ├── setio_l.png │ │ │ │ ├── singletracker.png │ │ │ │ ├── singletracker_l.png │ │ │ │ ├── smashrun.png │ │ │ │ ├── smashrun_l.png │ │ │ │ ├── sporttracks.png │ │ │ │ ├── sporttracks_l.png │ │ │ │ ├── strava.png │ │ │ │ ├── strava_l.png │ │ │ │ ├── trainasone.png │ │ │ │ ├── trainasone_l.png │ │ │ │ ├── trainerroad.png │ │ │ │ ├── trainerroad_l.png │ │ │ │ ├── trainingpeaks.png │ │ │ │ ├── trainingpeaks_l.png │ │ │ │ ├── velohero.png │ │ │ │ └── velohero_l.png │ │ │ ├── snow.png │ │ │ ├── starfield.svg │ │ │ ├── sync-arrow.png │ │ │ ├── sync-fail.png │ │ │ ├── sync-go.png │ │ │ ├── sync-info.png │ │ │ ├── sync-ok.png │ │ │ ├── sync-pause.png │ │ │ ├── sync-reauth.png │ │ │ ├── sync-settings.png │ │ │ ├── sync-spin.png │ │ │ ├── tapiriik-arabic.png │ │ │ ├── tapiriik-hebrew.png │ │ │ ├── tapiriik-hindi.png │ │ │ ├── tapiriik-inuktitut.png │ │ │ ├── tapiriik-punjabi.png │ │ │ ├── trainingpeaks_download │ │ │ └── unlink.png │ │ ├── js │ │ │ ├── Chart.min.js │ │ │ ├── bundles │ │ │ │ └── exercisyncApp.js │ │ │ ├── datepicker │ │ │ │ ├── pikaday-1.6.1.min.css │ │ │ │ └── pikaday-1.6.1.min.js │ │ │ ├── jquery.address-1.5.min.js │ │ │ ├── jstz.min.js │ │ │ ├── tapiriik-ng.js │ │ │ └── tapiriik.js │ │ └── site.webmanifest │ ├── templates │ │ ├── activities-dashboard.html │ │ ├── auth │ │ │ ├── disconnect.html │ │ │ └── login.html │ │ ├── config │ │ │ ├── aerobia.html │ │ │ ├── dropbox.html │ │ │ └── localexporter.html │ │ ├── dashboard.html │ │ ├── diag │ │ │ ├── dashboard.html │ │ │ ├── error.html │ │ │ ├── error_error_not_found.html │ │ │ ├── error_user_not_found.html │ │ │ ├── errors.html │ │ │ ├── graphs.html │ │ │ ├── login.html │ │ │ ├── payments.html │ │ │ └── user.html │ │ ├── donation.html │ │ ├── download.html │ │ ├── email │ │ │ ├── data_download.html │ │ │ ├── payment_confirm.html │ │ │ ├── payment_reclaim.html │ │ │ ├── payment_renew.html │ │ │ └── template.html │ │ ├── js-bridge.js │ │ ├── oauth-failure.html │ │ ├── oauth-return.html │ │ ├── payment.html │ │ ├── payments │ │ │ ├── claim.html │ │ │ ├── claim_return_fail.html │ │ │ ├── confirmed.html │ │ │ └── return.html │ │ ├── privacy.html │ │ ├── recent-sync-activity-block.html │ │ ├── rollback.html │ │ ├── service-blockingexception.html │ │ ├── service-button.html │ │ ├── settings-block.html │ │ ├── settings.html │ │ ├── site-iframe.html │ │ ├── site.html │ │ ├── static │ │ │ ├── contact.html │ │ │ ├── credits.html │ │ │ ├── faq.html │ │ │ ├── garmin_connect_bad_data.html │ │ │ └── garmin_connect_users.html │ │ ├── supported-activities.html │ │ └── supported-services-poll.html │ ├── templatetags │ │ ├── __init__.py │ │ ├── displayutils.py │ │ ├── services.py │ │ └── users.py │ ├── tests.py │ ├── views.py │ └── views │ │ ├── __init__.py │ │ ├── ab.py │ │ ├── account.py │ │ ├── activities_dashboard.py │ │ ├── aerobia │ │ └── __init__.py │ │ ├── auth.py │ │ ├── config │ │ └── __init__.py │ │ ├── dashboard.py │ │ ├── diagnostics.py │ │ ├── download.py │ │ ├── dropbox │ │ └── __init__.py │ │ ├── oauth │ │ └── __init__.py │ │ ├── payments │ │ └── __init__.py │ │ ├── privacy.py │ │ ├── rollback.py │ │ ├── settings.py │ │ ├── supported_activities.py │ │ ├── supported_services.py │ │ └── sync.py └── wsgi.py ├── tz_ingest.py └── webpack.config.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": [ 3 | "env", 4 | "react", 5 | "stage-2" 6 | ] 7 | } -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.log 2 | *.log.* 3 | *.pot 4 | *.pyc 5 | local_settings.py 6 | *.sublime-* 7 | private/ 8 | node_modules/ 9 | export_data/ 10 | webpack-stats.json 11 | .vscode/ 12 | .exercisync/ 13 | *.hot-update.js 14 | *.hot-update.json -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: python 2 | python: 3 | - "3.6" 4 | services: 5 | - mongodb 6 | - redis-server 7 | - rabbitmq 8 | install: 9 | - "pip install -r requirements.txt" 10 | - "cp tapiriik/local_settings.py.example tapiriik/local_settings.py" 11 | script: "python runtests.py" 12 | sudo: false 13 | -------------------------------------------------------------------------------- /NOTICE: -------------------------------------------------------------------------------- 1 | exercisync based on tapiriik project under the Apache 2.0 license, https://github.com/cpfair/tapiriik 2 | 3 | exercisync includes some components of Django, an open-source project under the BSD license, the text of which is available at https://github.com/django/django/blob/master/LICENSE 4 | 5 | exercisync requires, but does not include, several other freely available software packages - please refer to requirements.txt 6 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Build Status](https://travis-ci.org/Antash/exercisync.svg?branch=master)](https://travis-ci.org/Antash/exercisync) 2 | 3 | ## Credits 4 | This project is based on https://github.com/cpfair/tapiriik code by Collin Fair. 5 | 6 | ## Licensing 7 | exercisync is an Apache 2.0 Licensed open-source project. 8 | 9 | ## Translation 10 | To use locale translation, it requires the package gettext. 11 | To launch a new language, you have to generate the locale with : python3 manage.py makemessages -l 'fr' 12 | Secondly, after translation (edition of po files), compile the files : python manage.py compilemessages 13 | -------------------------------------------------------------------------------- /bootstrap.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Install system requirements 4 | 5 | # mongodb multipolygon geojson support needs at least mongodb 2.6. trusty has 2.4 by default 6 | sudo apt-key adv --keyserver hkp://keyserver.ubuntu.com:80 --recv 7F0CEB10 7 | echo 'deb http://downloads-distro.mongodb.org/repo/ubuntu-upstart dist 10gen' | sudo tee /etc/apt/sources.list.d/mongodb.list 8 | 9 | 10 | sudo apt-get update 11 | sudo apt-get install -y python3-pip libxml2-dev libxslt-dev zlib1g-dev git redis-server rabbitmq-server mongodb-org=2.6.9 12 | 13 | # Fix pip 14 | pip3 install --upgrade pip 15 | 16 | # Install app requirements 17 | pip install --upgrade -r /vagrant/requirements.txt 18 | 19 | # Fix the default python instance 20 | update-alternatives --install /usr/bin/python python /usr/bin/python2.7 1 21 | update-alternatives --install /usr/bin/python python /usr/bin/python3.4 2 22 | 23 | # Put in a default local_settings.py (if one doesn't exist) 24 | if [ ! -f /vagrant/tapiriik/local_settings.py ]; then 25 | cp /vagrant/tapiriik/local_settings.py.example /vagrant/tapiriik/local_settings.py 26 | # Generate credential storage keys 27 | python /vagrant/credentialstore_keygen.py >> /vagrant/tapiriik/local_settings.py 28 | fi 29 | 30 | -------------------------------------------------------------------------------- /credentialstore_keygen.py: -------------------------------------------------------------------------------- 1 | from Crypto.PublicKey import RSA 2 | key = RSA.generate(2048) 3 | 4 | print("CREDENTIAL_STORAGE_PRIVATE_KEY = " + key.exportKey("DER").__repr__()) 5 | print("CREDENTIAL_STORAGE_PUBLIC_KEY = " + key.publickey().exportKey("DER").__repr__()) 6 | -------------------------------------------------------------------------------- /email_cron.py: -------------------------------------------------------------------------------- 1 | from tapiriik.database import db 2 | from tapiriik.web.email import generate_message_from_template, send_email 3 | from tapiriik.services import Service 4 | from tapiriik.settings import WITHDRAWN_SERVICES 5 | from datetime import datetime, timedelta 6 | import os 7 | import math 8 | os.environ["DJANGO_SETTINGS_MODULE"] = "tapiriik.settings" 9 | 10 | # Renewal emails 11 | now = datetime.utcnow() 12 | expiry_window_open = now - timedelta(days=28) 13 | expiry_window_close = now 14 | expired_payments = db.payments.find({"Expiry": {"$gt": expiry_window_open, "$lt": expiry_window_close}, "ReminderEmailSent": {"$ne": True}}) 15 | 16 | for expired_payment in expired_payments: 17 | connected_user = db.users.find_one({"Payments._id": expired_payment["_id"]}) 18 | print("Composing renewal email for %s" % expired_payment["Email"]) 19 | if not connected_user: 20 | print("...no associated user") 21 | continue 22 | connected_services_names = [Service.FromID(x["Service"]).DisplayName for x in connected_user["ConnectedServices"] if x["Service"] not in WITHDRAWN_SERVICES] 23 | 24 | if len(connected_services_names) == 0: 25 | connected_services_names = ["fitness tracking"] 26 | elif len(connected_services_names) == 1: 27 | connected_services_names.append("other fitness tracking") 28 | if len(connected_services_names) > 1: 29 | connected_services_names = ", ".join(connected_services_names[:-1]) + " and " + connected_services_names[-1] + " accounts" 30 | else: 31 | connected_services_names = connected_services_names[0] + " accounts" 32 | 33 | subscription_days = round((expired_payment["Expiry"] - expired_payment["Timestamp"]).total_seconds() / (60 * 60 * 24)) 34 | subscription_fuzzy_time_map = { 35 | (0, 8): "few days", 36 | (8, 31): "few weeks", 37 | (31, 150): "few months", 38 | (150, 300): "half a year", 39 | (300, 999): "year" 40 | } 41 | subscription_fuzzy_time = [v for k,v in subscription_fuzzy_time_map.items() if k[0] <= subscription_days and k[1] > subscription_days][0] 42 | 43 | activity_records = db.activity_records.find_one({"UserID": connected_user["_id"]}) 44 | total_distance_synced = None 45 | if activity_records: 46 | total_distance_synced = sum([x["Distance"] for x in activity_records["Activities"] if x["Distance"]]) 47 | total_distance_synced = math.floor(total_distance_synced/1000 / 100) * 100 48 | 49 | context = { 50 | "account_list": connected_services_names, 51 | "subscription_days": subscription_days, 52 | "subscription_fuzzy_time": subscription_fuzzy_time, 53 | "distance": total_distance_synced 54 | } 55 | message, plaintext_message = generate_message_from_template("email/payment_renew.html", context) 56 | send_email(expired_payment["Email"], "tapiriik automatic synchronization expiry", message, plaintext_message=plaintext_message) 57 | db.payments.update({"_id": expired_payment["_id"]}, {"$set": {"ReminderEmailSent": True}}) 58 | print("...sent") 59 | -------------------------------------------------------------------------------- /manage.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import os 3 | import sys 4 | 5 | if __name__ == "__main__": 6 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tapiriik.settings") 7 | 8 | from django.core.management import execute_from_command_line 9 | 10 | execute_from_command_line(sys.argv) 11 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "exercisync", 3 | "version": "1.0.0", 4 | "description": "exercisync is an Apache 2.0 Licensed open-source project.", 5 | "main": "index.js", 6 | "scripts": { 7 | "test": "echo \"Error: no test specified\" && exit 1", 8 | "build": "webpack --config webpack.config.js --mode production", 9 | "watch": "webpack --config webpack.config.js --watch --mode development" 10 | }, 11 | "repository": { 12 | "type": "git", 13 | "url": "git+https://github.com/Antash/exercisync.git" 14 | }, 15 | "author": "Anton Ashmarin", 16 | "license": "Apache-2.0", 17 | "bugs": { 18 | "url": "https://github.com/Antash/exercisync/issues" 19 | }, 20 | "homepage": "https://github.com/Antash/exercisync#readme", 21 | "babel": { 22 | "presets": [ 23 | "env", 24 | "react", 25 | "stage-2" 26 | ] 27 | }, 28 | "devDependencies": { 29 | "babel": "^6.23.0", 30 | "babel-core": "^6.26.3", 31 | "babel-loader": "^7.1.4", 32 | "babel-preset-env": "^1.6.1", 33 | "babel-preset-react": "^6.24.1", 34 | "babel-preset-stage-2": "^6.24.1", 35 | "css-loader": "^0.28.11", 36 | "react": "^16.3.2", 37 | "react-dom": "^16.3.2", 38 | "react-hot-loader": "^4.1.2", 39 | "style-loader": "^0.21.0", 40 | "webpack": "^4.7.0", 41 | "webpack-bundle-tracker": "^0.3.0", 42 | "webpack-cli": "^3.1.2" 43 | }, 44 | "dependencies": { 45 | "react-select": "^1.2.1" 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /ratelimit_cron.py: -------------------------------------------------------------------------------- 1 | from tapiriik.services import Service 2 | from tapiriik.services.ratelimiting import RateLimit 3 | 4 | for svc in Service.List(): 5 | RateLimit.Refresh(svc.ID, svc.GlobalRateLimits) 6 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | requests 2 | pymongo==3.0.1 3 | pytz 4 | lxml 5 | dropbox 6 | python-dateutil 7 | Django==1.8.2 8 | pycryptodome 9 | celery 10 | django-pipeline==1.5.1 11 | requests_oauthlib==0.4.0 12 | redis==2.10.6 13 | django-ipware 14 | smashrun-client>=0.6.0 15 | beautifulsoup4 16 | isodate 17 | django-webpack-loader 18 | cssmin 19 | jsmin -------------------------------------------------------------------------------- /rollback_worker.py: -------------------------------------------------------------------------------- 1 | from tapiriik.database import db, close_connections 2 | from tapiriik.settings import RABBITMQ_BROKER_URL, MONGO_HOST, MONGO_FULL_WRITE_CONCERN 3 | from tapiriik import settings 4 | from datetime import datetime 5 | 6 | from celery import Celery 7 | from celery.signals import worker_shutdown 8 | from datetime import datetime 9 | 10 | class _celeryConfig: 11 | CELERY_ROUTES = { 12 | "rollback_worker.rollback_task": {"queue": "tapiriik-rollback"} 13 | } 14 | CELERYD_CONCURRENCY = 1 15 | CELERYD_PREFETCH_MULTIPLIER = 1 16 | 17 | celery_app = Celery('rollback_worker', broker=RABBITMQ_BROKER_URL) 18 | celery_app.config_from_object(_celeryConfig()) 19 | 20 | @worker_shutdown.connect 21 | def celery_shutdown(**kwargs): 22 | close_connections() 23 | 24 | @celery_app.task() 25 | def rollback_task(task_id): 26 | from tapiriik.services.rollback import RollbackTask 27 | print("Starting rollback task %s" % task_id) 28 | task = RollbackTask.Get(task_id) 29 | task.Execute() 30 | 31 | def schedule_rollback_task(task_id): 32 | rollback_task.apply_async(args=[task_id]) 33 | -------------------------------------------------------------------------------- /runtests.py: -------------------------------------------------------------------------------- 1 | import tapiriik.database 2 | tapiriik.database.db = tapiriik.database._connection["tapiriik_test"] 3 | tapiriik.database.cachedb = tapiriik.database._connection["tapiriik_cache_test"] 4 | 5 | from tapiriik.testing import * 6 | import unittest 7 | unittest.main() 8 | 9 | tapiriik.database._connection.drop_database("tapiriik_test") 10 | tapiriik.database._connection.drop_database("tapiriik_cache_test") 11 | -------------------------------------------------------------------------------- /sync_cpulimit.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | import subprocess 4 | 5 | cpulimit_procs = {} 6 | worker_cpu_limit = int(os.environ.get("TAPIRIIK_WORKER_CPU_LIMIT", 4)) 7 | 8 | while True: 9 | active_pids = [pid for pid in os.listdir('/proc') if pid.isdigit()] # Sorry, operating systems without procfs 10 | for pid in active_pids: 11 | try: 12 | proc_cmd = open("/proc/%s/cmdline" % pid, "r").read() 13 | except IOError: 14 | continue 15 | else: 16 | if "sync_worker.py" in proc_cmd: 17 | if pid not in cpulimit_procs or cpulimit_procs[pid].poll(): 18 | cpulimit_procs[pid] = subprocess.Popen(["cpulimit", "-l", str(worker_cpu_limit), "-p", pid]) 19 | 20 | for k in list(cpulimit_procs.keys()): 21 | if cpulimit_procs[k].poll(): 22 | cpulimit_procs[k].wait() 23 | del cpulimit_procs[k] 24 | 25 | time.sleep(1) 26 | -------------------------------------------------------------------------------- /sync_global_watchdog.py: -------------------------------------------------------------------------------- 1 | from tapiriik.database import db, close_connections 2 | from datetime import datetime, timedelta 3 | # I resisted calling this file sync_watchdog_watchdog.py, but that's what it is. 4 | # Normally a watchdog process runs on each server and detects hung/crashed 5 | # synchronization tasks, returning them to the queue for another worker to pick 6 | # up. Except, if the entire server goes down, the watchdog no longer runs and 7 | # users get stuck. So, we need a watchdog for the watchdogs. A separate process 8 | # reschedules users left stranded by a failed server/process. 9 | 10 | SERVER_WATCHDOG_TIMEOUT = timedelta(minutes=5) 11 | 12 | print("Global sync watchdog run at %s" % datetime.now()) 13 | 14 | for host_record in db.sync_watchdogs.find(): 15 | if datetime.utcnow() - host_record["Timestamp"] > SERVER_WATCHDOG_TIMEOUT: 16 | print("Releasing users held by %s (last check-in %s)" % (host_record["Host"], host_record["Timestamp"])) 17 | db.users.update({"SynchronizationHost": host_record["Host"]}, {"$unset": {"SynchronizationWorker": True}}, multi=True) 18 | db.sync_workers.remove({"Host": host_record["Host"]}, multi=True) 19 | db.sync_watchdogs.remove({"_id": host_record["_id"]}) 20 | 21 | close_connections() 22 | -------------------------------------------------------------------------------- /sync_poll_triggers.py: -------------------------------------------------------------------------------- 1 | from tapiriik.database import db, close_connections 2 | from tapiriik.requests_lib import patch_requests_source_address 3 | from tapiriik.settings import RABBITMQ_BROKER_URL, MONGO_HOST, MONGO_FULL_WRITE_CONCERN 4 | from tapiriik import settings 5 | from datetime import datetime 6 | 7 | if isinstance(settings.HTTP_SOURCE_ADDR, list): 8 | settings.HTTP_SOURCE_ADDR = settings.HTTP_SOURCE_ADDR[0] 9 | patch_requests_source_address((settings.HTTP_SOURCE_ADDR, 0)) 10 | 11 | from tapiriik.services import Service 12 | from celery import Celery 13 | from celery.signals import worker_shutdown 14 | from datetime import datetime 15 | 16 | class _celeryConfig: 17 | CELERY_ROUTES = { 18 | "sync_poll_triggers.trigger_poll": {"queue": "tapiriik-poll"} 19 | } 20 | CELERYD_CONCURRENCY = 1 # Otherwise the GC rate limiting breaks since file locking is per-process. 21 | CELERYD_PREFETCH_MULTIPLIER = 1 # The message queue could use some exercise. 22 | 23 | celery_app = Celery('sync_poll_triggers', broker=RABBITMQ_BROKER_URL) 24 | celery_app.config_from_object(_celeryConfig()) 25 | 26 | @worker_shutdown.connect 27 | def celery_shutdown(**kwargs): 28 | close_connections() 29 | 30 | @celery_app.task(acks_late=True) 31 | def trigger_poll(service_id, index): 32 | from tapiriik.auth import User 33 | print("Polling %s-%d" % (service_id, index)) 34 | svc = Service.FromID(service_id) 35 | affected_connection_external_ids = svc.PollPartialSyncTrigger(index) 36 | print("Triggering %d connections via %s-%d" % (len(affected_connection_external_ids), service_id, index)) 37 | 38 | # MONGO_FULL_WRITE_CONCERN because there was a race where users would get picked for synchronization before their service record was updated on the correct secondary 39 | # So it'd think the service wasn't triggered 40 | db.connections.update({"Service": service_id, "ExternalID": {"$in": affected_connection_external_ids}}, {"$set":{"TriggerPartialSync": True, "TriggerPartialSyncTimestamp": datetime.utcnow()}}, multi=True, w=MONGO_FULL_WRITE_CONCERN) 41 | 42 | affected_connection_ids = db.connections.find({"Service": svc.ID, "ExternalID": {"$in": affected_connection_external_ids}}, {"_id": 1}) 43 | affected_connection_ids = [x["_id"] for x in affected_connection_ids] 44 | trigger_users_query = User.PaidUserMongoQuery() 45 | trigger_users_query.update({"ConnectedServices.ID": {"$in": affected_connection_ids}}) 46 | trigger_users_query.update({"Config.suppress_auto_sync": {"$ne": True}}) 47 | db.users.update(trigger_users_query, {"$set": {"NextSynchronization": datetime.utcnow()}}, multi=True) # It would be nicer to use the Sync.Schedule... method, but I want to cleanly do this in bulk 48 | 49 | db.poll_stats.insert({"Service": service_id, "Index": index, "Timestamp": datetime.utcnow(), "TriggerCount": len(affected_connection_external_ids)}) 50 | 51 | def schedule_trigger_poll(): 52 | schedule_data = list(db.trigger_poll_scheduling.find()) 53 | print("Scheduler run at %s" % datetime.now()) 54 | for svc in Service.List(): 55 | if svc.PartialSyncTriggerRequiresPolling: 56 | print("Checking %s's %d poll indexes" % (svc.ID, svc.PartialSyncTriggerPollMultiple)) 57 | for idx in range(svc.PartialSyncTriggerPollMultiple): 58 | svc_schedule = [x for x in schedule_data if x["Service"] == svc.ID and x["Index"] == idx] 59 | if not svc_schedule: 60 | svc_schedule = {"Service": svc.ID, "Index": idx, "LastScheduled": datetime.min} 61 | else: 62 | svc_schedule = svc_schedule[0] 63 | 64 | if datetime.utcnow() - svc_schedule["LastScheduled"] > svc.PartialSyncTriggerPollInterval: 65 | print("Scheduling %s-%d" % (svc.ID, idx)) 66 | svc_schedule["LastScheduled"] = datetime.utcnow() 67 | trigger_poll.apply_async(args=[svc.ID, idx], expires=svc.PartialSyncTriggerPollInterval.total_seconds(), time_limit=svc.PartialSyncTriggerPollInterval.total_seconds()) 68 | db.trigger_poll_scheduling.update({"Service": svc.ID, "Index": idx}, svc_schedule, upsert=True) 69 | 70 | if __name__ == "__main__": 71 | schedule_trigger_poll() 72 | close_connections() -------------------------------------------------------------------------------- /sync_remote_triggers.py: -------------------------------------------------------------------------------- 1 | from tapiriik.database import db, close_connections 2 | from tapiriik.settings import RABBITMQ_BROKER_URL, MONGO_FULL_WRITE_CONCERN 3 | from datetime import datetime 4 | from celery import Celery 5 | from celery.signals import worker_shutdown 6 | 7 | class _celeryConfig: 8 | CELERY_ROUTES = { 9 | "sync_remote_triggers.trigger_remote": {"queue": "tapiriik-remote-trigger"} 10 | } 11 | CELERYD_CONCURRENCY = 1 # Otherwise the GC rate limiting breaks since file locking is per-process. 12 | CELERYD_PREFETCH_MULTIPLIER = 1 # The message queue could use some exercise. 13 | 14 | celery_app = Celery('sync_remote_triggers', broker=RABBITMQ_BROKER_URL) 15 | celery_app.config_from_object(_celeryConfig()) 16 | 17 | @worker_shutdown.connect 18 | def celery_shutdown(**kwargs): 19 | close_connections() 20 | 21 | @celery_app.task(acks_late=True) 22 | def trigger_remote(service_id, affected_connection_external_ids_with_payloads): 23 | from tapiriik.auth import User 24 | from tapiriik.services import Service 25 | svc = Service.FromID(service_id) 26 | affected_connection_ids = list() 27 | 28 | for item in affected_connection_external_ids_with_payloads: 29 | if isinstance(item, list): 30 | external_id, payload = item 31 | else: 32 | external_id = item 33 | payload = None 34 | update_connection_query = {"$set":{"TriggerPartialSync": True, "TriggerPartialSyncTimestamp": datetime.utcnow()}} 35 | if payload is not None: 36 | update_connection_query.update({"$push": {"TriggerPartialSyncPayloads": payload, "$slice": -90}}) 37 | record = db.connections.find_and_modify({"Service": svc.ID, "ExternalID": external_id}, update_connection_query, w=MONGO_FULL_WRITE_CONCERN) 38 | if record is not None: 39 | affected_connection_ids.append(record["_id"]) 40 | 41 | trigger_users_query = User.PaidUserMongoQuery() 42 | trigger_users_query.update({"ConnectedServices.ID": {"$in": affected_connection_ids}}) 43 | trigger_users_query.update({"Config.suppress_auto_sync": {"$ne": True}}) 44 | db.users.update(trigger_users_query, {"$set": {"NextSynchronization": datetime.utcnow()}}, multi=True) # It would be nicer to use the Sync.Schedule... method, but I want to cleanly do this in bulk 45 | -------------------------------------------------------------------------------- /sync_scheduler.py: -------------------------------------------------------------------------------- 1 | from tapiriik.database import db 2 | from tapiriik.sync import Sync 3 | from datetime import datetime 4 | from pymongo.read_preferences import ReadPreference 5 | import kombu 6 | import time 7 | import uuid 8 | 9 | Sync.InitializeWorkerBindings() 10 | 11 | producer = kombu.Producer(Sync._channel, Sync._exchange) 12 | 13 | while True: 14 | generation = str(uuid.uuid4()) 15 | queueing_at = datetime.utcnow() 16 | users = list(db.users.with_options(read_preference=ReadPreference.PRIMARY).find( 17 | { 18 | "NextSynchronization": {"$lte": datetime.utcnow()}, 19 | "QueuedAt": {"$exists": False} 20 | }, 21 | { 22 | "_id": True, 23 | "SynchronizationHostRestriction": True 24 | } 25 | )) 26 | scheduled_ids = [x["_id"] for x in users] 27 | print("Found %d users at %s" % (len(scheduled_ids), datetime.utcnow())) 28 | db.users.update({"_id": {"$in": scheduled_ids}}, {"$set": {"QueuedAt": queueing_at, "QueuedGeneration": generation}, "$unset": {"NextSynchronization": True}}, multi=True) 29 | print("Marked %d users as queued at %s" % (len(scheduled_ids), datetime.utcnow())) 30 | for user in users: 31 | producer.publish({"user_id": str(user["_id"]), "generation": generation}, routing_key=user["SynchronizationHostRestriction"] if "SynchronizationHostRestriction" in user and user["SynchronizationHostRestriction"] else "") 32 | print("Scheduled %d users at %s" % (len(scheduled_ids), datetime.utcnow())) 33 | 34 | time.sleep(1) 35 | -------------------------------------------------------------------------------- /sync_watchdog.py: -------------------------------------------------------------------------------- 1 | from tapiriik.database import db, close_connections 2 | from tapiriik.sync import SyncStep 3 | import os 4 | import signal 5 | import socket 6 | from datetime import timedelta, datetime 7 | 8 | print("Sync watchdog run at %s" % datetime.now()) 9 | 10 | host = socket.gethostname() 11 | 12 | for worker in db.sync_workers.find({"Host": host}): 13 | # Does the process still exist? 14 | alive = True 15 | try: 16 | os.kill(worker["Process"], 0) 17 | except os.error: 18 | print("%s is no longer alive" % worker) 19 | alive = False 20 | 21 | # Has it been stalled for too long? 22 | if worker["State"] == SyncStep.List: 23 | timeout = timedelta(minutes=45) # This can take a loooooooong time 24 | else: 25 | timeout = timedelta(minutes=10) # But everything else shouldn't 26 | 27 | if alive and worker["Heartbeat"] < datetime.utcnow() - timeout: 28 | print("%s timed out" % worker) 29 | os.kill(worker["Process"], signal.SIGKILL) 30 | alive = False 31 | 32 | # Clear it from the database if it's not alive. 33 | if not alive: 34 | db.sync_workers.remove({"_id": worker["_id"]}) 35 | # Unlock users attached to it. 36 | db.users.update({"SynchronizationWorker": worker["Process"], "SynchronizationHost": host}, {"$unset":{"SynchronizationWorker": True}}, multi=True) 37 | 38 | db.sync_watchdogs.update({"Host": host}, {"Host": host, "Timestamp": datetime.utcnow()}, upsert=True) 39 | 40 | close_connections() 41 | -------------------------------------------------------------------------------- /sync_worker.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | import os 3 | # I'm trying to track down where some missing seconds are going in the sync process 4 | # Will grep these out of the log at some later date 5 | def worker_message(state): 6 | print("Sync worker %d %s at %s" % (os.getpid(), state, datetime.now())) 7 | 8 | worker_message("booting") 9 | 10 | from tapiriik.requests_lib import patch_requests_with_default_timeout, patch_requests_source_address 11 | from tapiriik import settings 12 | from tapiriik.database import db, close_connections 13 | from pymongo import ReturnDocument 14 | import sys 15 | import subprocess 16 | import socket 17 | 18 | RecycleInterval = 2 # Time spent rebooting workers < time spent wrangling Python memory management. 19 | 20 | oldCwd = os.getcwd() 21 | WorkerVersion = subprocess.Popen(["git", "rev-parse", "HEAD"], stdout=subprocess.PIPE, cwd=os.path.dirname(__file__)).communicate()[0].strip() 22 | os.chdir(oldCwd) 23 | 24 | def sync_heartbeat(state, user=None): 25 | db.sync_workers.update({"_id": heartbeat_rec_id}, {"$set": {"Heartbeat": datetime.utcnow(), "State": state, "User": user}}) 26 | 27 | worker_message("initialized") 28 | 29 | # Moved this flush before the sync_workers upsert for a rather convoluted reason: 30 | # Some of the sync servers were encountering filesystem corruption, causing the FS to be remounted as read-only. 31 | # Then, when a sync worker would start, it would insert a record in sync_workers then immediately die upon calling flush - since output is piped to a log file on the read-only FS. 32 | # Supervisor would dutifully restart the worker again and again, causing sync_workers to quickly fill up. 33 | # ...which is a problem, since it doesn't have indexes on Process or Host - what later lookups were based on. So, the database would be brought to a near standstill. 34 | # Theoretically, the watchdog would clean up these records soon enough - but since it too logs to a file, it would crash removing only a few stranded records 35 | # By flushing the logs before we insert, it should crash before filling that collection up. 36 | # (plus, we no longer query with Process/Host in sync_hearbeat) 37 | 38 | sys.stdout.flush() 39 | heartbeat_rec = db.sync_workers.find_one_and_update( 40 | { 41 | "Process": os.getpid(), 42 | "Host": socket.gethostname() 43 | }, { 44 | "$set": { 45 | "Process": os.getpid(), 46 | "Host": socket.gethostname(), 47 | "Heartbeat": datetime.utcnow(), 48 | "Startup": datetime.utcnow(), 49 | "Version": WorkerVersion, 50 | "Index": settings.WORKER_INDEX, 51 | "State": "startup" 52 | } 53 | }, upsert=True, 54 | return_document=ReturnDocument.AFTER) 55 | heartbeat_rec_id = heartbeat_rec["_id"] 56 | 57 | patch_requests_with_default_timeout(timeout=60) 58 | 59 | if isinstance(settings.HTTP_SOURCE_ADDR, list): 60 | settings.HTTP_SOURCE_ADDR = settings.HTTP_SOURCE_ADDR[settings.WORKER_INDEX % len(settings.HTTP_SOURCE_ADDR)] 61 | patch_requests_source_address((settings.HTTP_SOURCE_ADDR, 0)) 62 | 63 | print(" %d -> Index %s\n -> Interface %s" % (os.getpid(), settings.WORKER_INDEX, settings.HTTP_SOURCE_ADDR)) 64 | 65 | # We defer including the main body of the application till here so the settings aren't captured before we've set them up. 66 | # The better way would be to defer initializing services until they're requested, but it's 10:30 and this will work just as well. 67 | from tapiriik.sync import Sync 68 | 69 | Sync.InitializeWorkerBindings() 70 | 71 | sync_heartbeat("ready") 72 | 73 | worker_message("ready") 74 | 75 | Sync.PerformGlobalSync(heartbeat_callback=sync_heartbeat, version=WorkerVersion, max_users=RecycleInterval) 76 | 77 | worker_message("shutting down cleanly") 78 | db.sync_workers.remove({"_id": heartbeat_rec_id}) 79 | close_connections() 80 | worker_message("shut down") 81 | sys.stdout.flush() 82 | -------------------------------------------------------------------------------- /tapiriik/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/__init__.py -------------------------------------------------------------------------------- /tapiriik/auth/credential_storage.py: -------------------------------------------------------------------------------- 1 | from Crypto.Cipher import PKCS1_OAEP 2 | from Crypto.PublicKey import RSA 3 | from Crypto.Hash import SHA 4 | from Crypto import Random 5 | from tapiriik.settings import CREDENTIAL_STORAGE_PUBLIC_KEY, CREDENTIAL_STORAGE_PRIVATE_KEY 6 | import copy 7 | 8 | #### note about tapiriik and credential storage #### 9 | # Some services require a username and password for every action - so they need to be stored in recoverable form 10 | # (namely: Garmin Connect's current "API") 11 | # I've done my best to mitigate the risk that these credentials ever be compromised, but the risk can never be eliminated 12 | # If you're not comfortable with it, you can opt to not have your credentials stored, instead entering them on every sync 13 | 14 | class CredentialStore: 15 | def Init(): 16 | _key = RSA.importKey(CREDENTIAL_STORAGE_PRIVATE_KEY if CREDENTIAL_STORAGE_PRIVATE_KEY else CREDENTIAL_STORAGE_PUBLIC_KEY) 17 | CredentialStore._cipher = PKCS1_OAEP.new(_key) 18 | 19 | def Encrypt(cred): 20 | data = CredentialStore._cipher.encrypt(cred.encode("UTF-8")) 21 | 22 | # We store the plaintext credential so that the web server can use it later during the same request. 23 | # After the request terminates, it'll be GC'd more-or-less as quickly as the incoming request data itself. 24 | return ShadowedCredential(data, cred) 25 | 26 | def Decrypt(data): 27 | # Check if we encrypted the data in the same session, and use that instead (we might not have the private key). 28 | if isinstance(data, ShadowedCredential): 29 | return data.plaintext 30 | 31 | # I kind of doubt anyone could get away with a timing attack on the sycnhronization workers. 32 | # But, dear comment-reader, I'm sure you're now contemplating the possibilities... 33 | # So PKCS#1 OAEP it is. 34 | return CredentialStore._cipher.decrypt(data).decode("UTF-8") 35 | 36 | def FlattenShadowedCredentials(auth_dict): 37 | flat_auth_dict = copy.deepcopy(auth_dict) 38 | for k, v in auth_dict.items(): 39 | if isinstance(v, ShadowedCredential): 40 | flat_auth_dict[k] = v.ciphertext 41 | return flat_auth_dict 42 | 43 | class ShadowedCredential: 44 | def __init__(self, ciphertext, plaintext): 45 | self.ciphertext = ciphertext 46 | self.plaintext = plaintext 47 | 48 | CredentialStore.Init() 49 | -------------------------------------------------------------------------------- /tapiriik/auth/totp.py: -------------------------------------------------------------------------------- 1 | import time 2 | import base64 3 | import math 4 | import hmac 5 | import hashlib 6 | import struct 7 | 8 | 9 | class TOTP: 10 | def Get(secret): 11 | counter = struct.pack(">Q", int(time.time() / 30)) 12 | key = base64.b32decode(secret.upper().encode()) 13 | csp = hmac.new(key, counter, hashlib.sha1) 14 | res = csp.digest() 15 | offset = res[19] & 0xf 16 | code_pre = struct.unpack(">I", res[offset:offset + 4])[0] 17 | code_pre = code_pre & 0x7fffffff 18 | return int(code_pre % (math.pow(10, 6))) 19 | -------------------------------------------------------------------------------- /tapiriik/database/__init__.py: -------------------------------------------------------------------------------- 1 | from pymongo import MongoClient, MongoReplicaSetClient 2 | from tapiriik.settings import MONGO_HOST, MONGO_REPLICA_SET, MONGO_CLIENT_OPTIONS, REDIS_HOST, REDIS_CLIENT_OPTIONS 3 | 4 | # MongoDB 5 | 6 | client_class = MongoClient if not MONGO_REPLICA_SET else MongoReplicaSetClient 7 | if MONGO_REPLICA_SET: 8 | MONGO_CLIENT_OPTIONS["replicaSet"] = MONGO_REPLICA_SET 9 | 10 | _connection = client_class(host=MONGO_HOST, **MONGO_CLIENT_OPTIONS) 11 | 12 | db = _connection["tapiriik"] 13 | cachedb = _connection["tapiriik_cache"] 14 | tzdb = _connection["tapiriik_tz"] 15 | # The main db currently has an unfortunate lock contention rate 16 | ratelimit = _connection["tapiriik_ratelimit"] 17 | 18 | # Redis 19 | if REDIS_HOST: 20 | import redis as redis_client 21 | redis = redis_client.Redis(host=REDIS_HOST, **REDIS_CLIENT_OPTIONS) 22 | else: 23 | redis = None # Must be defined 24 | 25 | def close_connections(): 26 | try: 27 | _connection.close() 28 | except: 29 | pass 30 | 31 | import atexit 32 | atexit.register(close_connections) 33 | -------------------------------------------------------------------------------- /tapiriik/database/tz.py: -------------------------------------------------------------------------------- 1 | from tapiriik.database import tzdb 2 | from bson.son import SON 3 | 4 | def TZLookup(lat, lng): 5 | pt = [lng, lat] 6 | res = tzdb.boundaries.find_one({"Boundary": {"$geoIntersects": {"$geometry": {"type":"Point", "coordinates": pt}}}}, {"TZID": True}) 7 | if not res: 8 | res = tzdb.boundaries.find_one({"Boundary": {"$near": {"$geometry": {"type": "Point", "coordinates": pt}, "$maxDistance": 200000}}}, {"TZID": True}) 9 | res = res["TZID"] if res else None 10 | if not res or res == "uninhabited": 11 | res = round(lng / 15) 12 | return res 13 | -------------------------------------------------------------------------------- /tapiriik/frontend/app.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import ReactDOM from "react-dom"; 3 | 4 | import AerobiaConfig from "./components/aerobiaConfig"; 5 | import LocalExporterConfig from "./components/localExporter/localExporterConfig"; 6 | import EmptyComponent from "./components/empty"; 7 | 8 | var componentToRender; 9 | 10 | switch(window.props.component.toLowerCase()) { 11 | case "aerobia": 12 | componentToRender = AerobiaConfig; 13 | break; 14 | case "localexporter": 15 | componentToRender = LocalExporterConfig; 16 | break; 17 | default: 18 | componentToRender = EmptyComponent; 19 | } 20 | 21 | ReactDOM.render( 22 | React.createElement(componentToRender, window.props), 23 | window.react_mount, 24 | ) -------------------------------------------------------------------------------- /tapiriik/frontend/components/aerobiaConfig.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | import CSRFToken from './csrftoken' 4 | import RuleList from './ruleList' 5 | import ExportConfig from './exportConfig' 6 | 7 | const urlGears = (userid, token) => 8 | `http://old15.aerobia.ru/users/${userid}/equipments?authentication_token=${token}` 9 | 10 | class AerobiaConfig extends React.Component { 11 | constructor(props) { 12 | super(props) 13 | this.state = { 14 | gears: [], 15 | requestFailed: false, 16 | gearRules: props.config.gearRules, 17 | export: props.config.export 18 | } 19 | } 20 | 21 | // componentDidMount() { 22 | // fetch("https://cors-anywhere.herokuapp.com/" + urlGears(this.props.aerobiaId, this.props.userToken)) 23 | // .then(response => { 24 | // if (!response.ok) { 25 | // throw Error("Network request failed"); 26 | // } 27 | // return response.text(); 28 | // }) 29 | // .then(d => { 30 | // var parser = new DOMParser(); 31 | // var page = parser.parseFromString(d, "text/html"); 32 | // var gears = []; 33 | // var itemNodes = page.getElementsByClassName("item"); 34 | // if (!itemNodes.length) { 35 | // return gears; 36 | // } 37 | // itemNodes = Array.prototype.slice.call(itemNodes); 38 | // itemNodes.forEach(n => { 39 | // var itemData = n.getElementsByTagName("p")[0]; 40 | // itemData = itemData.getElementsByTagName("a")[0]; 41 | // var gearUrl = itemData.getAttribute("href").split("/"); 42 | // gears.push({ 43 | // id: gearUrl[gearUrl.length - 1], 44 | // name: itemData.innerText 45 | // }); 46 | // }); 47 | // return gears; 48 | // }) 49 | // .then(d => { 50 | // this.setState({ 51 | // gears: d 52 | // }) 53 | // }, () => { 54 | // this.setState({ 55 | // requestFailed: true 56 | // }) 57 | // }) 58 | // } 59 | 60 | updateExportSettings(newState) { 61 | this.setState({export: newState}); 62 | } 63 | 64 | updateGearRules(newState) { 65 | this.setState({gearRules: newState.rules}); 66 | } 67 | 68 | render() { 69 | const { sportTypes } = this.props; 70 | // if (this.state.gears.length == 0) 71 | // return ( 72 | //
73 | //

Aerobia advanced settings

74 | //

Loading gear...

75 | //
76 | // ); 77 | 78 | return ( 79 |
80 |

Aerobia advanced settings

81 | 82 |
83 | this.updateExportSettings(newState)} 86 | /> 87 |
88 | 89 | {/*
90 |

Default gear rules:

91 | this.updateGearRules(newState)} 96 | /> 97 |
*/} 98 |
99 | 100 | 101 |
102 | 103 |
104 | 105 |
106 | ); 107 | } 108 | } 109 | 110 | export default AerobiaConfig; 111 | -------------------------------------------------------------------------------- /tapiriik/frontend/components/csrftoken.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | export function getCookie(name) { 4 | var cookieValue = null; 5 | if (document.cookie && document.cookie !== '') { 6 | var cookies = document.cookie.split(';'); 7 | for (var i = 0; i < cookies.length; i++) { 8 | var cookie = jQuery.trim(cookies[i]); 9 | if (cookie.substring(0, name.length + 1) === (name + '=')) { 10 | cookieValue = decodeURIComponent(cookie.substring(name.length + 1)); 11 | break; 12 | } 13 | } 14 | } 15 | return cookieValue; 16 | } 17 | 18 | var csrftoken = getCookie('csrftoken'); 19 | 20 | const CSRFToken = () => { 21 | return ( 22 | 23 | ); 24 | }; 25 | export default CSRFToken; -------------------------------------------------------------------------------- /tapiriik/frontend/components/empty.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | 3 | export default class EmptyComponent extends React.Component { 4 | render() { 5 | return ( 6 | Component missing! 7 | ); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /tapiriik/frontend/components/exportConfig.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | 3 | export default class ExportConfig extends React.Component { 4 | constructor(props) { 5 | super(props) 6 | this.state = { 7 | upload_media_content: props.data.upload_media_content, 8 | min_report_length: props.data.min_report_length 9 | } 10 | } 11 | 12 | toggleChecked() { 13 | this.setState({ upload_media_content: !this.state.upload_media_content }, () => { 14 | this.props.handleChange(this.state); 15 | }); 16 | } 17 | 18 | lengthChanged(e) { 19 | this.setState({ min_report_length: parseInt(e.target.value) }, () => { 20 | this.props.handleChange(this.state); 21 | }); 22 | } 23 | 24 | render() { 25 | return ( 26 |
27 |
28 | Загружать только фотографии и отчеты 29 | 34 |
35 |
36 | Игнорировать отчеты без фотографий короче 37 | 42 | символов 43 |
44 |
45 | ) 46 | } 47 | } -------------------------------------------------------------------------------- /tapiriik/frontend/components/localExporter/localExporterConfig.jsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import CSRFToken from "../csrftoken"; 3 | 4 | export default class LocalExporterConfig extends React.Component { 5 | constructor(props) { 6 | super(props) 7 | this.state = { 8 | download_only_media_content: props.config.download_only_media_content 9 | } 10 | } 11 | 12 | toggleChecked() { 13 | this.setState({ download_only_media_content: !this.state.download_only_media_content }); 14 | } 15 | 16 | submitForm() { 17 | $.post('/localExporterConfig', {'config': JSON.stringify(this.state)}); 18 | window.location.href = "/"; 19 | } 20 | 21 | render() { 22 | return ( 23 |
24 |

Local Exporter settings

25 |
26 |
27 | Download only posts and photos 28 | 33 |
34 |
35 |
36 | 37 | 38 |
39 | 40 |
41 | 42 |
43 | ) 44 | } 45 | } -------------------------------------------------------------------------------- /tapiriik/frontend/components/ruleLine.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import Select from 'react-select' 3 | import 'react-select/dist/react-select.css' 4 | 5 | export default class RuleLine extends React.Component { 6 | constructor(props) { 7 | super(props) 8 | this.state = { 9 | id: props.data.id, 10 | selectedSport: props.data.sport, 11 | selectedGear: props.data.gear, 12 | } 13 | } 14 | 15 | handleSportChange = (selectedOption) => { 16 | var sport = ''; 17 | if (selectedOption) { 18 | sport = selectedOption.value; 19 | } 20 | this.setState({ selectedSport: sport }, () => { 21 | this.props.handleChange(this.state); 22 | }); 23 | console.log(`Selected: ${sport}`); 24 | } 25 | 26 | handleGearChange = (selectedOption) => { 27 | var gear = []; 28 | if (selectedOption) { 29 | gear = selectedOption.map((o) => o.value); 30 | } 31 | this.setState({ selectedGear: gear }, () => { 32 | this.props.handleChange(this.state); 33 | }); 34 | console.log(`Selected: ${gear}`); 35 | } 36 | 37 | handleDelete = (event) => { 38 | event.preventDefault(); 39 | this.props.handleDelete(); 40 | } 41 | 42 | render() { 43 | const { selectedSport, selectedGear } = this.state; 44 | const { sportTypes, gears } = this.props 45 | const sports = sportTypes.map(function (e) { 46 | var obj = {}; 47 | obj['value'] = e; 48 | obj['label'] = e; 49 | return obj; 50 | }); 51 | const gearData = gears.map(function (e) { 52 | var obj = {}; 53 | obj['value'] = e.id; 54 | obj['label'] = e.name; 55 | return obj; 56 | }); 57 | return ( 58 |
59 | 60 | this.handleGearChange(obj)} 75 | options={gearData} 76 | /> 77 | 78 |
80 | ); 81 | } 82 | } -------------------------------------------------------------------------------- /tapiriik/frontend/components/ruleList.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react' 2 | import RuleLine from './ruleLine' 3 | 4 | export default class RuleList extends React.Component { 5 | constructor(props) { 6 | super(props) 7 | this.state = { 8 | rules: props.data 9 | } 10 | } 11 | 12 | handleNewRule = (event) => { 13 | var timestamp = (new Date()).getTime(); 14 | this.state.rules.push({ 15 | id: timestamp 16 | }); 17 | this.setState({ rules: this.state.rules }); 18 | console.log('Rule added'); 19 | event.preventDefault(); 20 | } 21 | 22 | deleteRule(id) { 23 | this.setState(prevState => ({ 24 | rules: prevState.rules.filter(e => e.id != id) 25 | }), () => { 26 | this.props.handleChange(this.state); 27 | }); 28 | console.log('Rule deleted'); 29 | } 30 | 31 | updateRule(newState) { 32 | let rules = [...this.state.rules]; 33 | let index = rules.findIndex(el => el.id === newState.id); 34 | rules[index] = {...rules[index], gear: newState.selectedGear}; 35 | rules[index] = {...rules[index], sport: newState.selectedSport}; 36 | this.setState({ rules }, () => { 37 | this.props.handleChange(this.state); 38 | }); 39 | } 40 | 41 | render() { 42 | const { sportTypes, gears } = this.props 43 | var ruleLines = this.state.rules.map(function (rule) { 44 | return ( 45 | this.deleteRule(rule.id)} 51 | handleChange={(newState) => this.updateRule(newState)} /> 52 | ); 53 | }.bind(this)); 54 | return ( 55 |
56 | {ruleLines} 57 |
59 | ); 60 | } 61 | } -------------------------------------------------------------------------------- /tapiriik/local_settings.py.example: -------------------------------------------------------------------------------- 1 | # Look in settings.py for more settings to override 2 | # including mongodb, rabbitmq, and redis connection settings 3 | 4 | # This is the url that is used for redirects after logging in to each service 5 | # It only needs to be accessible to the client browser 6 | WEB_ROOT = "http://localhost:8000" 7 | 8 | # This is where sync logs show up 9 | # It is the only directory that needs to be writable by the webapp user 10 | USER_SYNC_LOGS = "./" 11 | 12 | # Folder to store user data 13 | USER_DATA_FILES = "./user_export_data" 14 | 15 | # These settings are used to communicate with each respective service 16 | # Register your installation with each service to get these values 17 | 18 | # http://beginnertriathlete.com/discussion/contact.asp?department=api 19 | BT_APIKEY = "####" 20 | 21 | DROPBOX_FULL_APP_KEY = "####" 22 | DROPBOX_FULL_APP_SECRET = "####" 23 | 24 | DROPBOX_APP_KEY = "####" 25 | DROPBOX_APP_SECRET = "####" 26 | 27 | ENDOMONDO_CLIENT_KEY = "####" 28 | ENDOMONDO_CLIENT_SECRET = "####" 29 | 30 | MOTIVATO_PREMIUM_USERS_LIST_URL = "http://..." 31 | 32 | NIKEPLUS_CLIENT_NAME = "####" 33 | NIKEPLUS_CLIENT_ID = "####" 34 | NIKEPLUS_CLIENT_SECRET = "####" 35 | 36 | PULSSTORY_CLIENT_ID="####" 37 | PULSSTORY_CLIENT_SECRET="####" 38 | 39 | RUNKEEPER_CLIENT_ID="####" 40 | RUNKEEPER_CLIENT_SECRET="####" 41 | 42 | RWGPS_APIKEY = "####" 43 | 44 | SETIO_CLIENT_ID = "####" 45 | SETIO_CLIENT_SECRET = "####" 46 | 47 | SINGLETRACKER_CLIENT_ID = "####" 48 | SINGLETRACKER_CLIENT_SECRET = "####" 49 | 50 | # See http://api.smashrun.com for info. 51 | # For now, you need to email hi@smashrun.com for access 52 | SMASHRUN_CLIENT_ID = "####" 53 | SMASHRUN_CLIENT_SECRET = "####" 54 | 55 | SPORTTRACKS_CLIENT_ID = "####" 56 | SPORTTRACKS_CLIENT_SECRET = "####" 57 | 58 | STRAVA_CLIENT_SECRET = "####" 59 | STRAVA_CLIENT_ID = "####" 60 | STRAVA_RATE_LIMITS = [] 61 | 62 | POLAR_CLIENT_SECRET = "####" 63 | POLAR_CLIENT_ID = "####" 64 | POLAR_RATE_LIMITS = [] 65 | 66 | TRAINASONE_SERVER_URL = "https://beta.trainasone.com" 67 | TRAINASONE_CLIENT_SECRET = "####" 68 | TRAINASONE_CLIENT_ID = "####" 69 | 70 | TRAININGPEAKS_CLIENT_ID = "####" 71 | TRAININGPEAKS_CLIENT_SECRET = "####" 72 | TRAININGPEAKS_CLIENT_SCOPE = "cats:cuddle dogs:throw-frisbee" 73 | TRAININGPEAKS_API_BASE_URL = "https://api.trainingpeaks.com" 74 | TRAININGPEAKS_OAUTH_BASE_URL = "https://oauth.trainingpeaks.com" 75 | 76 | DECATHLONCOACH_CLIENT_SECRET = "####" 77 | DECATHLONCOACH_CLIENT_ID = "####" 78 | DECATHLONCOACH_API_KEY = "####" 79 | -------------------------------------------------------------------------------- /tapiriik/locale/fr/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/locale/fr/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /tapiriik/locale/ru/LC_MESSAGES/django.mo: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/locale/ru/LC_MESSAGES/django.mo -------------------------------------------------------------------------------- /tapiriik/messagequeue/__init__.py: -------------------------------------------------------------------------------- 1 | from kombu import Connection 2 | from tapiriik.settings import RABBITMQ_BROKER_URL 3 | mq = Connection(RABBITMQ_BROKER_URL, transport_options={'confirm_publish': True}) 4 | mq.connect() 5 | -------------------------------------------------------------------------------- /tapiriik/payments/__init__.py: -------------------------------------------------------------------------------- 1 | from .payments import Payments 2 | from .external.provider_base import ExternalPaymentProvider 3 | -------------------------------------------------------------------------------- /tapiriik/payments/external/__init__.py: -------------------------------------------------------------------------------- 1 | from .provider_base import * 2 | from .motivato import * 3 | -------------------------------------------------------------------------------- /tapiriik/payments/external/motivato.py: -------------------------------------------------------------------------------- 1 | from .provider_base import ExternalPaymentProvider 2 | from tapiriik.database import db 3 | from tapiriik.settings import MOTIVATO_PREMIUM_USERS_LIST_URL 4 | import requests 5 | 6 | class MotivatoExternalPaymentProvider(ExternalPaymentProvider): 7 | ID = "motivato" 8 | 9 | def RefreshPaymentStateForExternalIDs(self, external_ids): 10 | from tapiriik.services import Service, ServiceRecord 11 | external_ids = [str(x) for x in external_ids] 12 | connections = [ServiceRecord(x) for x in db.connections.find({"Service": "motivato", "ExternalID": {"$in": external_ids}})] 13 | users = db.users.find({"ConnectedServices.ID": {"$in": [x._id for x in connections]}}) 14 | for user in users: 15 | my_connection = [x for x in connections if x._id in [y["ID"] for y in user["ConnectedServices"]]][0] 16 | # Defer to the actual service module, where all the session stuff is set up 17 | state = Service.FromID("motivato")._getPaymentState(my_connection) 18 | self.ApplyPaymentState(user, state, my_connection.ExternalID, duration=None) 19 | 20 | def RefreshPaymentState(self): 21 | from tapiriik.services import ServiceRecord 22 | from tapiriik.payments import Payments 23 | from tapiriik.auth import User 24 | 25 | external_ids = requests.get(MOTIVATO_PREMIUM_USERS_LIST_URL).json() 26 | connections = [ServiceRecord(x) for x in db.connections.find({"Service": "motivato", "ExternalID": {"$in": external_ids}})] 27 | users = list(db.users.find({"ConnectedServices.ID": {"$in": [x._id for x in connections]}})) 28 | payments = [] 29 | 30 | # Pull relevant payment objects and associate with users 31 | for user in users: 32 | my_connection = [x for x in connections if x._id in [y["ID"] for y in user["ConnectedServices"]]][0] 33 | pmt = Payments.EnsureExternalPayment(self.ID, my_connection.ExternalID, duration=None) 34 | payments.append(pmt) 35 | User.AssociateExternalPayment(user, pmt, skip_deassoc=True) 36 | 37 | # Bulk-remove these payments from users who don't own them (more or less - it'll leave anyone who switched remote accounts) 38 | db.users.update({"_id": {"$nin": [x["_id"] for x in users]}}, {"$pull": {"ExternalPayments": {"_id": {"$in": [x["_id"] for x in payments]}}}}, multi=True) 39 | 40 | # We don't bother unsetting users who are no longer on the list - they'll be refreshed at their next sync 41 | 42 | 43 | ExternalPaymentProvider.Register(MotivatoExternalPaymentProvider()) -------------------------------------------------------------------------------- /tapiriik/payments/external/provider_base.py: -------------------------------------------------------------------------------- 1 | 2 | class ExternalPaymentProvider: 3 | _providers = [] 4 | def FromID(id): 5 | return [x for x in ExternalPaymentProvider._providers if x.ID == id][0] 6 | 7 | def Register(instance): 8 | ExternalPaymentProvider._providers.append(instance) 9 | 10 | ID = None 11 | def RefreshPaymentStateForExternalIDs(self, external_ids): 12 | raise NotImplemented 13 | 14 | def RefreshPaymentState(self): 15 | raise NotImplemented 16 | 17 | def ApplyPaymentState(self, user, state, externalID, duration=None): 18 | from tapiriik.payments import Payments 19 | from tapiriik.auth import User 20 | if state: 21 | pmt = Payments.EnsureExternalPayment(self.ID, externalID, duration) 22 | User.AssociateExternalPayment(user, pmt) 23 | else: 24 | Payments.ExpireExternalPayment(self.ID, externalID) 25 | -------------------------------------------------------------------------------- /tapiriik/requests_lib.py: -------------------------------------------------------------------------------- 1 | # For whatever reason there's no built-in way to specify a global timeout for requests operations. 2 | # socket.setdefaulttimeout doesn't work since requests overriddes the default with its own default. 3 | # There's probably a better way to do this in requests 2.x, but... 4 | 5 | def patch_requests_with_default_timeout(timeout): 6 | import requests 7 | old_request = requests.Session.request 8 | def new_request(*args, **kwargs): 9 | if "timeout" not in kwargs: 10 | kwargs["timeout"] = timeout 11 | return old_request(*args, **kwargs) 12 | requests.Session.request = new_request 13 | 14 | def patch_requests_no_verify_ssl(): 15 | import requests 16 | old_request = requests.Session.request 17 | def new_request(*args, **kwargs): 18 | kwargs.update({"verify": False}) 19 | return old_request(*args, **kwargs) 20 | requests.Session.request = new_request 21 | 22 | # Not really patching requests here, but... 23 | def patch_requests_source_address(new_source_address): 24 | import socket 25 | old_create_connection = socket.create_connection 26 | def new_create_connection(address, timeout=None, source_address=None): 27 | if address[1] in [80, 443]: 28 | return old_create_connection(address, timeout, new_source_address) 29 | else: 30 | return old_create_connection(address, timeout, source_address) 31 | socket.create_connection = new_create_connection 32 | 33 | def patch_requests_user_agent(user_agent): 34 | import requests 35 | old_request = requests.Session.request 36 | def new_request(*args, **kwargs): 37 | headers = kwargs.get("headers", {}) 38 | headers = headers if headers else {} 39 | headers["User-Agent"] = user_agent 40 | kwargs["headers"] = headers 41 | return old_request(*args, **kwargs) 42 | requests.Session.request = new_request 43 | -------------------------------------------------------------------------------- /tapiriik/services/Aerobia/__init__.py: -------------------------------------------------------------------------------- 1 | from .aerobia import * 2 | -------------------------------------------------------------------------------- /tapiriik/services/BeginnerTriathlete/__init__.py: -------------------------------------------------------------------------------- 1 | from .beginnertriathlete import * 2 | -------------------------------------------------------------------------------- /tapiriik/services/DecathlonCoach/__init__.py: -------------------------------------------------------------------------------- 1 | from .decathloncoach import * 2 | -------------------------------------------------------------------------------- /tapiriik/services/Dropbox/__init__.py: -------------------------------------------------------------------------------- 1 | from .dropbox import * 2 | -------------------------------------------------------------------------------- /tapiriik/services/Endomondo/__init__.py: -------------------------------------------------------------------------------- 1 | from .endomondo import * -------------------------------------------------------------------------------- /tapiriik/services/GarminConnect/__init__.py: -------------------------------------------------------------------------------- 1 | from .garminconnect import * 2 | -------------------------------------------------------------------------------- /tapiriik/services/LocalExporter/__init__.py: -------------------------------------------------------------------------------- 1 | from .localExporter import * 2 | -------------------------------------------------------------------------------- /tapiriik/services/Motivato/__init__.py: -------------------------------------------------------------------------------- 1 | from .motivato import * -------------------------------------------------------------------------------- /tapiriik/services/NikePlus/__init__.py: -------------------------------------------------------------------------------- 1 | from .nikeplus import * -------------------------------------------------------------------------------- /tapiriik/services/PolarFlow/__init__.py: -------------------------------------------------------------------------------- 1 | from .polarflow import * 2 | -------------------------------------------------------------------------------- /tapiriik/services/PolarPersonalTrainer/__init__.py: -------------------------------------------------------------------------------- 1 | from .polarpersonaltrainer import * 2 | from .pptToTcx import convert -------------------------------------------------------------------------------- /tapiriik/services/Pulsstory/__init__.py: -------------------------------------------------------------------------------- 1 | from .pulsstory import PulsstoryService -------------------------------------------------------------------------------- /tapiriik/services/RideWithGPS/__init__.py: -------------------------------------------------------------------------------- 1 | from .rwgps import * -------------------------------------------------------------------------------- /tapiriik/services/RunKeeper/__init__.py: -------------------------------------------------------------------------------- 1 | from .runkeeper import * -------------------------------------------------------------------------------- /tapiriik/services/Setio/__init__.py: -------------------------------------------------------------------------------- 1 | from .setio import * 2 | -------------------------------------------------------------------------------- /tapiriik/services/Singletracker/__init__.py: -------------------------------------------------------------------------------- 1 | from .singletracker import * 2 | -------------------------------------------------------------------------------- /tapiriik/services/Smashrun/__init__.py: -------------------------------------------------------------------------------- 1 | from .smashrun import SmashrunService 2 | -------------------------------------------------------------------------------- /tapiriik/services/SportTracks/__init__.py: -------------------------------------------------------------------------------- 1 | from .sporttracks import * -------------------------------------------------------------------------------- /tapiriik/services/Strava/__init__.py: -------------------------------------------------------------------------------- 1 | from .strava import * 2 | -------------------------------------------------------------------------------- /tapiriik/services/TrainAsONE/__init__.py: -------------------------------------------------------------------------------- 1 | from .trainasone import * 2 | -------------------------------------------------------------------------------- /tapiriik/services/TrainerRoad/__init__.py: -------------------------------------------------------------------------------- 1 | from .trainerroad import * -------------------------------------------------------------------------------- /tapiriik/services/TrainingPeaks/__init__.py: -------------------------------------------------------------------------------- 1 | from .trainingpeaks import * -------------------------------------------------------------------------------- /tapiriik/services/VeloHero/__init__.py: -------------------------------------------------------------------------------- 1 | from .velohero import * -------------------------------------------------------------------------------- /tapiriik/services/__init__.py: -------------------------------------------------------------------------------- 1 | from .service_base import * 2 | from .api import * 3 | from tapiriik.services.RunKeeper import RunKeeperService 4 | RunKeeper = RunKeeperService() 5 | from tapiriik.services.Strava import StravaService 6 | Strava = StravaService() 7 | from tapiriik.services.Endomondo import EndomondoService 8 | Endomondo = EndomondoService() 9 | from tapiriik.services.Dropbox import DropboxService 10 | Dropbox = DropboxService() 11 | from tapiriik.services.GarminConnect import GarminConnectService 12 | GarminConnect = GarminConnectService() 13 | from tapiriik.services.SportTracks import SportTracksService 14 | SportTracks = SportTracksService() 15 | from tapiriik.services.RideWithGPS import RideWithGPSService 16 | RideWithGPS = RideWithGPSService() 17 | from tapiriik.services.TrainAsONE import TrainAsONEService 18 | TrainAsONE = TrainAsONEService() 19 | from tapiriik.services.TrainingPeaks import TrainingPeaksService 20 | TrainingPeaks = TrainingPeaksService() 21 | from tapiriik.services.Motivato import MotivatoService 22 | Motivato = MotivatoService() 23 | from tapiriik.services.NikePlus import NikePlusService 24 | NikePlus = NikePlusService() 25 | from tapiriik.services.VeloHero import VeloHeroService 26 | VeloHero = VeloHeroService() 27 | from tapiriik.services.TrainerRoad import TrainerRoadService 28 | TrainerRoad = TrainerRoadService() 29 | from tapiriik.services.Smashrun import SmashrunService 30 | Smashrun = SmashrunService() 31 | from tapiriik.services.BeginnerTriathlete import BeginnerTriathleteService 32 | BeginnerTriathlete = BeginnerTriathleteService() 33 | from tapiriik.services.Pulsstory import PulsstoryService 34 | Pulsstory = PulsstoryService() 35 | from tapiriik.services.Setio import SetioService 36 | Setio = SetioService() 37 | from tapiriik.services.Singletracker import SingletrackerService 38 | Singletracker = SingletrackerService() 39 | from tapiriik.services.Aerobia import AerobiaService 40 | Aerobia = AerobiaService() 41 | from tapiriik.services.PolarFlow import PolarFlowService 42 | PolarFlow = PolarFlowService() 43 | from tapiriik.services.DecathlonCoach import DecathlonCoachService 44 | DecathlonCoach = DecathlonCoachService() 45 | from tapiriik.services.PolarPersonalTrainer import PolarPersonalTrainerService 46 | PolarPersonalTrainer = PolarPersonalTrainerService() 47 | from tapiriik.services.LocalExporter import LocalExporterService 48 | LocalExporter = LocalExporterService() 49 | 50 | PRIVATE_SERVICES = [] 51 | try: 52 | from private.tapiriik.services import * 53 | except ImportError: 54 | pass 55 | 56 | from .service import * 57 | from .service_record import * 58 | -------------------------------------------------------------------------------- /tapiriik/services/api.py: -------------------------------------------------------------------------------- 1 | class ServiceExceptionScope: 2 | Account = "account" 3 | Service = "service" 4 | # Unlike Account and Service-level blocking exceptions, these are implemented via ActivityRecord.FailureCounts 5 | # Eventually, all errors might be stored in ActivityRecords 6 | Activity = "activity" 7 | 8 | class ServiceException(Exception): 9 | def __init__(self, message, scope=ServiceExceptionScope.Service, block=False, user_exception=None, trigger_exhaustive=True): 10 | Exception.__init__(self, message) 11 | self.Message = message 12 | self.UserException = user_exception 13 | self.Block = block 14 | self.Scope = scope 15 | self.TriggerExhaustive = trigger_exhaustive 16 | 17 | def __str__(self): 18 | return self.Message + " (user " + str(self.UserException) + " )" 19 | 20 | class ServiceWarning(ServiceException): 21 | pass 22 | 23 | class APIException(ServiceException): 24 | pass 25 | 26 | class APIWarning(ServiceWarning): 27 | pass 28 | 29 | # Theoretically, APIExcludeActivity should actually be a ServiceException with block=True, scope=Activity 30 | # It's on the to-do list. 31 | 32 | class APIExcludeActivity(Exception): 33 | def __init__(self, message, activity=None, activity_id=None, permanent=True, user_exception=None): 34 | Exception.__init__(self, message) 35 | self.Message = message 36 | self.Activity = activity 37 | self.ExternalActivityID = activity_id 38 | self.Permanent = permanent 39 | self.UserException = user_exception 40 | 41 | def __str__(self): 42 | return self.Message + " (activity " + str(self.ExternalActivityID) + ")" 43 | 44 | class UserExceptionType: 45 | # Account-level exceptions (not a hardcoded thing, just to keep these seperate) 46 | Authorization = "auth" 47 | RenewPassword = "renew_password" 48 | Locked = "locked" 49 | AccountFull = "full" 50 | AccountExpired = "expired" 51 | AccountUnpaid = "unpaid" # vs. expired, which implies it was at some point function, via payment or trial or otherwise. 52 | NonAthleteAccount = "non_athlete_account" # trainingpeaks 53 | NotAValidEmail = "not_a_valid_email" # localExporter 54 | EmailsDoNotMatch = "emails_do_not_match" # localExporter 55 | GCUploadConsent = "gc_upload_consent" # EU User must grant upload consent on GC 56 | 57 | # Activity-level exceptions 58 | FlowException = "flow" 59 | Private = "private" 60 | NoSupplier = "nosupplier" 61 | NotTriggered = "notrigger" 62 | Deferred = "deferred" # They've instructed us not to synchronize activities for some time after they complete 63 | PredatesWindow = "predates_window" # They've instructed us not to synchronize activities before some date 64 | RateLimited = "ratelimited" 65 | MissingCredentials = "credentials_missing" # They forgot to check the "Remember these details" box 66 | NotConfigured = "config_missing" # Don't think this error is even possible any more. 67 | StationaryUnsupported = "stationary" 68 | NonGPSUnsupported = "nongps" 69 | TypeUnsupported = "type_unsupported" 70 | InsufficientData = "data_insufficient" # Some services demand more data than others provide (ahem, N+) 71 | DownloadError = "download" 72 | ListingError = "list" # Cases when a service fails listing, so nothing can be uploaded to it. 73 | UploadError = "upload" 74 | SanityError = "sanity" 75 | Corrupt = "corrupt" # Kind of a scary term for what's generally "some data is missing" 76 | Untagged = "untagged" 77 | LiveTracking = "live" 78 | UnknownTZ = "tz_unknown" 79 | System = "system" 80 | Other = "other" 81 | 82 | class UserException: 83 | def __init__(self, type, extra=None, intervention_required=False, clear_group=None): 84 | self.Type = type 85 | self.Extra = extra # Unimplemented - displayed as part of the error message. 86 | self.InterventionRequired = intervention_required # Does the user need to dismiss this error? 87 | self.ClearGroup = clear_group if clear_group else type # Used to group error messages displayed to the user, and let them clear a group that share a common cause. 88 | -------------------------------------------------------------------------------- /tapiriik/services/exception_tools.py: -------------------------------------------------------------------------------- 1 | def strip_context(exc): 2 | exc.__context__ = exc.__cause__ = exc.__traceback__ = None 3 | return exc -------------------------------------------------------------------------------- /tapiriik/services/ratelimiting.py: -------------------------------------------------------------------------------- 1 | from tapiriik.database import ratelimit as rl_db 2 | from pymongo.read_preferences import ReadPreference 3 | from datetime import datetime, timedelta 4 | import math 5 | 6 | class RateLimitExceededException(Exception): 7 | pass 8 | 9 | class RateLimit: 10 | def Limit(key): 11 | current_limits = rl_db.limits.find({"Key": key}, {"Max": 1, "Count": 1}) 12 | for limit in current_limits: 13 | if limit["Max"] < limit["Count"]: 14 | # We can't continue without exceeding this limit 15 | # Don't want to halt the synchronization worker to wait for 15min-1 hour 16 | # So... 17 | raise RateLimitExceededException() 18 | rl_db.limits.update({"Key": key}, {"$inc": {"Count": 1}}, multi=True) 19 | 20 | def Refresh(key, limits): 21 | # Limits is in format [(timespan, max-count),...] 22 | # The windows are anchored at midnight 23 | # The timespan is used to uniquely identify limit instances between runs 24 | midnight = datetime.combine(datetime.utcnow().date(), datetime.min.time()) 25 | time_since_midnight = (datetime.utcnow() - midnight) 26 | 27 | rl_db.limits.remove({"Key": key, "Expires": {"$lt": datetime.utcnow()}}) 28 | current_limits = list(rl_db.limits.with_options(read_preference=ReadPreference.PRIMARY).find({"Key": key}, {"Duration": 1})) 29 | missing_limits = [x for x in limits if x[0].total_seconds() not in [limit["Duration"] for limit in current_limits]] 30 | for limit in missing_limits: 31 | window_start = midnight + timedelta(seconds=math.floor(time_since_midnight.total_seconds()/limit[0].total_seconds()) * limit[0].total_seconds()) 32 | window_end = window_start + limit[0] 33 | rl_db.limits.insert({"Key": key, "Count": 0, "Duration": limit[0].total_seconds(), "Max": limit[1], "Expires": window_end}) 34 | -------------------------------------------------------------------------------- /tapiriik/services/rollback.py: -------------------------------------------------------------------------------- 1 | from tapiriik.database import db 2 | from tapiriik.auth import User 3 | from tapiriik.services import Service 4 | import datetime 5 | import logging 6 | import json 7 | from bson.objectid import ObjectId 8 | logger = logging.getLogger(__name__) 9 | 10 | class RollbackTask: 11 | def __new__(cls, dbRec): 12 | if not dbRec: 13 | return None 14 | return super(RollbackTask, cls).__new__(cls) 15 | 16 | def __init__(self, dbRec): 17 | self.__dict__.update(dbRec) 18 | 19 | def _create(user): 20 | # Pull all the records that need to be rolled back 21 | logger.info("Finding activities for %s" % user["_id"]) 22 | conns = User.GetConnectionRecordsByUser(user) 23 | my_services = [conn.Service.ID for conn in conns] 24 | my_ext_ids = [conn.ExternalID for conn in conns] 25 | logger.info("Scanning uploads table for %s accounts with %s extids" % (my_services, my_ext_ids)) 26 | uploads = db.uploaded_activities.find({"Service": {"$in": my_services}, "UserExternalID": {"$in": my_ext_ids}}) 27 | pending_deletions = {} 28 | for upload in uploads: 29 | svc = upload["Service"] 30 | upload_id = upload["ExternalID"] 31 | svc_ext_id = upload["UserExternalID"] 32 | # Filter back down to the pairing we actually need 33 | if my_services.index(svc) != my_ext_ids.index(svc_ext_id): 34 | continue 35 | if svc not in pending_deletions: 36 | pending_deletions[svc] = [] 37 | pending_deletions[svc].append(upload_id) 38 | 39 | # Another case of "I should have an ORM" 40 | return RollbackTask({"PendingDeletions": pending_deletions}) 41 | 42 | def Create(user): 43 | task = RollbackTask._create(user) 44 | uid = db.rollback_tasks.insert({"PendingDeletions": task.PendingDeletions, "Created": datetime.datetime.utcnow(), "UserID": user["_id"]}) 45 | logger.info("Created rollback task %s" % uid) 46 | task._id = uid 47 | return task 48 | 49 | def Get(id): 50 | dbRec = db.rollback_tasks.find_one({"_id": ObjectId(id)}) 51 | if not dbRec: 52 | return 53 | return RollbackTask(dbRec) 54 | 55 | def json(self): 56 | # Augment with the requisite URLs 57 | self.ActivityURLs = {svc: {} for svc in self.PendingDeletions.keys()} 58 | for svc_id, urls in self.ActivityURLs.items(): 59 | svc = Service.FromID(svc_id) 60 | for upload in self.PendingDeletions[svc_id]: 61 | try: 62 | urls[upload] = svc.UserUploadedActivityURL(upload) 63 | except NotImplementedError: 64 | pass 65 | self.PendingDeletionCount = sum([len(v) for k, v in self.PendingDeletions.items()]) 66 | dthandler = lambda obj: obj.isoformat() if isinstance(obj, datetime.datetime) or isinstance(obj, datetime.date) else str(obj) 67 | return json.dumps(self.__dict__, default=dthandler) 68 | 69 | def Schedule(self): 70 | db.rollback_tasks.update({"_id": self._id}, {"$set": {"Scheduled": datetime.datetime.utcnow()}}) 71 | from rollback_worker import schedule_rollback_task 72 | schedule_rollback_task(str(self._id)) 73 | 74 | def Execute(self): 75 | logger.info("Starting rollback %s" % self._id) 76 | deletion_status = {} 77 | user = User.Get(self.UserID) 78 | for svc_id, upload_ids in self.PendingDeletions.items(): 79 | svcrec = User.GetConnectionRecord(user, svc_id) 80 | deletion_status[svc_id] = {} 81 | if not svcrec.Service.SupportsActivityDeletion: 82 | continue 83 | for upload_id in upload_ids: 84 | logger.info("Deleting activity %s on %s" % (upload_id, svc_id)) 85 | try: 86 | svcrec.Service.DeleteActivity(svcrec, upload_id) 87 | except Exception as e: 88 | deletion_status[svc_id][str(upload_id)] = False 89 | logger.exception("Deletion failed - %s" % e) 90 | else: 91 | deletion_status[svc_id][str(upload_id)] = True 92 | db.rollback_tasks.update({"_id": self._id}, {"$set": {"DeletionStatus": deletion_status}}) 93 | logger.info("Finished rollback %s" % self._id) 94 | -------------------------------------------------------------------------------- /tapiriik/services/service_record.py: -------------------------------------------------------------------------------- 1 | from tapiriik.database import cachedb, db 2 | import copy 3 | 4 | class ServiceRecord: 5 | def __new__(cls, dbRec): 6 | if not dbRec: 7 | return None 8 | return super(ServiceRecord, cls).__new__(cls) 9 | def __init__(self, dbRec): 10 | self.__dict__.update(dbRec) 11 | def __repr__(self): 12 | return " " + str(self.__dict__) 13 | 14 | def __eq__(self, other): 15 | return self._id == other._id 16 | 17 | def __ne__(self, other): 18 | return not self.__eq__(other) 19 | 20 | def __deepcopy__(self, x): 21 | return ServiceRecord(self.__dict__) 22 | 23 | ExcludedActivities = {} 24 | Config = {} 25 | PartialSyncTriggerSubscribed = False 26 | 27 | @property 28 | def Service(self): 29 | from tapiriik.services import Service 30 | return Service.FromID(self.__dict__["Service"]) 31 | 32 | def HasExtendedAuthorizationDetails(self, persisted_only=False): 33 | if not self.Service.RequiresExtendedAuthorizationDetails: 34 | return False 35 | if "ExtendedAuthorization" in self.__dict__ and self.ExtendedAuthorization: 36 | return True 37 | if persisted_only: 38 | return False 39 | return cachedb.extendedAuthDetails.find({"ID": self._id}).limit(1).count() 40 | 41 | def SetPartialSyncTriggerSubscriptionState(self, subscribed): 42 | db.connections.update({"_id": self._id}, {"$set": {"PartialSyncTriggerSubscribed": subscribed}}) 43 | 44 | def GetConfiguration(self): 45 | from tapiriik.services import Service 46 | svc = self.Service 47 | config = copy.deepcopy(Service._globalConfigurationDefaults) 48 | config.update(svc.ConfigurationDefaults) 49 | config.update(self.Config) 50 | return config 51 | 52 | def SetConfiguration(self, config, no_save=False, drop_existing=False): 53 | from tapiriik.services import Service 54 | sparseConfig = {} 55 | if not drop_existing: 56 | sparseConfig = copy.deepcopy(self.GetConfiguration()) 57 | sparseConfig.update(config) 58 | 59 | svc = self.Service 60 | svc.ConfigurationUpdating(self, config, self.GetConfiguration()) 61 | keys_to_delete = [] 62 | for k, v in sparseConfig.items(): 63 | if (k in svc.ConfigurationDefaults and svc.ConfigurationDefaults[k] == v) or (k in Service._globalConfigurationDefaults and Service._globalConfigurationDefaults[k] == v): 64 | keys_to_delete.append(k) # it's the default, we can not store it 65 | for k in keys_to_delete: 66 | del sparseConfig[k] 67 | self.Config = sparseConfig 68 | if not no_save: 69 | db.connections.update({"_id": self._id}, {"$set": {"Config": sparseConfig}}) 70 | -------------------------------------------------------------------------------- /tapiriik/services/sessioncache.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | from tapiriik.database import redis 3 | import pickle 4 | 5 | class SessionCache: 6 | def __init__(self, scope, lifetime, freshen_on_get=False): 7 | self._lifetime = lifetime 8 | self._autorefresh = freshen_on_get 9 | self._scope = scope 10 | self._cacheKey = "sessioncache:%s:%s" % (self._scope, "%s") 11 | 12 | def Get(self, pk, freshen=False): 13 | res = redis.get(self._cacheKey % pk) 14 | if res: 15 | try: 16 | res = pickle.loads(res) 17 | except pickle.UnpicklingError: 18 | self.Delete(pk) 19 | res = None 20 | else: 21 | if self._autorefresh or freshen: 22 | redis.expire(self._cacheKey % pk, self._lifetime) 23 | return res 24 | 25 | def Set(self, pk, value, lifetime=None): 26 | lifetime = lifetime or self._lifetime 27 | redis.setex(self._cacheKey % pk, pickle.dumps(value), lifetime) 28 | 29 | def Delete(self, pk): 30 | redis.delete(self._cacheKey % pk) 31 | -------------------------------------------------------------------------------- /tapiriik/services/statistic_calculator.py: -------------------------------------------------------------------------------- 1 | from datetime import timedelta 2 | from .interchange import WaypointType 3 | 4 | class ActivityStatisticCalculator: 5 | ImplicitPauseTime = timedelta(minutes=1, seconds=5) 6 | 7 | def CalculateDistance(act, startWpt=None, endWpt=None): 8 | import math 9 | dist = 0 10 | altHold = None # seperate from the lastLoc variable, since we want to hold the altitude as long as required 11 | lastTimestamp = lastLoc = None 12 | 13 | flatWaypoints = act.GetFlatWaypoints() 14 | 15 | if not startWpt: 16 | startWpt = flatWaypoints[0] 17 | if not endWpt: 18 | endWpt = flatWaypoints[-1] 19 | 20 | for x in range(flatWaypoints.index(startWpt), flatWaypoints.index(endWpt) + 1): 21 | timeDelta = flatWaypoints[x].Timestamp - lastTimestamp if lastTimestamp else None 22 | lastTimestamp = flatWaypoints[x].Timestamp 23 | 24 | if flatWaypoints[x].Type == WaypointType.Pause or (timeDelta and timeDelta > ActivityStatisticCalculator.ImplicitPauseTime): 25 | lastLoc = None # don't count distance while paused 26 | continue 27 | 28 | loc = flatWaypoints[x].Location 29 | if loc is None or loc.Longitude is None or loc.Latitude is None: 30 | # Used to throw an exception in this case, but the TCX schema allows for location-free waypoints, so we'll just patch over it. 31 | continue 32 | 33 | if loc and lastLoc: 34 | altHold = lastLoc.Altitude if lastLoc.Altitude is not None else altHold 35 | latRads = loc.Latitude * math.pi / 180 36 | meters_lat_degree = 1000 * 111.13292 + 1.175 * math.cos(4 * latRads) - 559.82 * math.cos(2 * latRads) 37 | meters_lon_degree = 1000 * 111.41284 * math.cos(latRads) - 93.5 * math.cos(3 * latRads) 38 | dx = (loc.Longitude - lastLoc.Longitude) * meters_lon_degree 39 | dy = (loc.Latitude - lastLoc.Latitude) * meters_lat_degree 40 | if loc.Altitude is not None and altHold is not None: # incorporate the altitude when possible 41 | dz = loc.Altitude - altHold 42 | else: 43 | dz = 0 44 | dist += math.sqrt(dx ** 2 + dy ** 2 + dz ** 2) 45 | lastLoc = loc 46 | 47 | return dist 48 | 49 | def CalculateTimerTime(act, startWpt=None, endWpt=None): 50 | flatWaypoints = [] 51 | for lap in act.Laps: 52 | flatWaypoints.append(lap.Waypoints) 53 | 54 | if len(flatWaypoints) < 3: 55 | # Either no waypoints, or one at the start and one at the end 56 | raise ValueError("Not enough waypoints to calculate timer time") 57 | duration = timedelta(0) 58 | if not startWpt: 59 | startWpt = flatWaypoints[0] 60 | if not endWpt: 61 | endWpt = flatWaypoints[-1] 62 | lastTimestamp = None 63 | for x in range(flatWaypoints.index(startWpt), flatWaypoints.index(endWpt) + 1): 64 | wpt = flatWaypoints[x] 65 | delta = wpt.Timestamp - lastTimestamp if lastTimestamp else None 66 | lastTimestamp = wpt.Timestamp 67 | if wpt.Type is WaypointType.Pause: 68 | lastTimestamp = None 69 | elif delta and delta > act.ImplicitPauseTime: 70 | delta = None # Implicit pauses 71 | if delta: 72 | duration += delta 73 | if duration.total_seconds() == 0 and startWpt is None and endWpt is None: 74 | raise ValueError("Zero-duration activity") 75 | return duration 76 | 77 | def CalculateAverageMaxHR(act, startWpt=None, endWpt=None): 78 | flatWaypoints = act.GetFlatWaypoints() 79 | 80 | # Python can handle 600+ digit numbers, think it can handle this 81 | maxHR = 0 82 | cumulHR = 0 83 | samples = 0 84 | 85 | if not startWpt: 86 | startWpt = flatWaypoints[0] 87 | if not endWpt: 88 | endWpt = flatWaypoints[-1] 89 | 90 | for x in range(flatWaypoints.index(startWpt), flatWaypoints.index(endWpt) + 1): 91 | wpt = flatWaypoints[x] 92 | if wpt.HR: 93 | if wpt.HR > maxHR: 94 | maxHR = wpt.HR 95 | cumulHR += wpt.HR 96 | samples += 1 97 | 98 | if not samples: 99 | return None, None 100 | 101 | cumulHR = cumulHR / samples 102 | return cumulHR, maxHR 103 | 104 | 105 | -------------------------------------------------------------------------------- /tapiriik/services/stream_sampling.py: -------------------------------------------------------------------------------- 1 | class StreamSampler: 2 | def SampleWithCallback(callback, streams): 3 | """ 4 | *streams should be a dict in format {"stream1":[(ts1,val1), (ts2, val2)...]...} where ts is a numerical offset from the activity start. 5 | Expect callback(time_offset, stream1=value1, stream2=value2) in chronological order. Stream values may be None 6 | All samples are represented - none are dropped 7 | """ 8 | 9 | # Collate the individual streams into discrete waypoints. 10 | # There is no global sampling rate - waypoints are created for every new datapoint in any stream (simultaneous datapoints are included in the same waypoint) 11 | # Resampling is based on the last known value of the stream - no interpolation or nearest-neighbour. 12 | 13 | streamData = streams 14 | streams = list(streams.keys()) 15 | print("Handling streams %s" % streams) 16 | 17 | stream_indices = dict([(stream, -1) for stream in streams]) # -1 meaning the stream has yet to start 18 | stream_lengths = dict([(stream, len(streamData[stream])) for stream in streams]) 19 | 20 | currentTimeOffset = 0 21 | 22 | while True: 23 | advance_stream = None 24 | advance_offset = None 25 | for stream in streams: 26 | if stream_indices[stream] + 1 == stream_lengths[stream]: 27 | continue # We're at the end - can't advance 28 | if advance_offset is None or streamData[stream][stream_indices[stream] + 1][0] - currentTimeOffset < advance_offset: 29 | advance_offset = streamData[stream][stream_indices[stream] + 1][0] - currentTimeOffset 30 | advance_stream = stream 31 | if not advance_stream: 32 | break # We've hit the end of every stream, stop 33 | # Update the current time offset based on the key advancing stream (others may still be behind) 34 | currentTimeOffset = streamData[advance_stream][stream_indices[advance_stream] + 1][0] 35 | # Advance streams with the current timestamp, including advance_stream 36 | for stream in streams: 37 | if stream_indices[stream] + 1 == stream_lengths[stream]: 38 | continue # We're at the end - can't advance 39 | if streamData[stream][stream_indices[stream] + 1][0] == currentTimeOffset: # Don't need to consider <, as then that stream would be advance_stream 40 | stream_indices[stream] += 1 41 | callbackDataArgs = {} 42 | for stream in streams: 43 | if stream_indices[stream] >= 0: 44 | callbackDataArgs[stream] = streamData[stream][stream_indices[stream]][1] 45 | callback(currentTimeOffset, **callbackDataArgs) 46 | -------------------------------------------------------------------------------- /tapiriik/sync/__init__.py: -------------------------------------------------------------------------------- 1 | from .sync import * -------------------------------------------------------------------------------- /tapiriik/testing/__init__.py: -------------------------------------------------------------------------------- 1 | from .sync import * 2 | from .interchange import * 3 | from .gpx import * 4 | from .statistics import * 5 | from .tcx import * 6 | -------------------------------------------------------------------------------- /tapiriik/testing/gpx.py: -------------------------------------------------------------------------------- 1 | from tapiriik.testing.testtools import TestTools, TapiriikTestCase 2 | from tapiriik.services.gpx import GPXIO 3 | 4 | 5 | class GPXTests(TapiriikTestCase): 6 | def test_constant_representation(self): 7 | ''' ensures that gpx import/export is symetric ''' 8 | 9 | svcA, other = TestTools.create_mock_services() 10 | svcA.SupportsHR = svcA.SupportsCadence = svcA.SupportsTemp = True 11 | svcA.SupportsPower = svcA.SupportsCalories = False 12 | act = TestTools.create_random_activity(svcA, tz=True, withPauses=False) 13 | 14 | mid = GPXIO.Dump(act) 15 | 16 | act2 = GPXIO.Parse(bytes(mid, "UTF-8")) 17 | act2.TZ = act.TZ # we need to fake this since local TZ isn't defined in GPX files, and TZ discovery will flail with random activities 18 | act2.AdjustTZ() 19 | act.Stats.Distance = act2.Stats.Distance = None # same here 20 | 21 | self.assertActivitiesEqual(act2, act) 22 | -------------------------------------------------------------------------------- /tapiriik/testing/interchange.py: -------------------------------------------------------------------------------- 1 | from tapiriik.testing.testtools import TestTools, TapiriikTestCase 2 | 3 | from tapiriik.services import Service 4 | from tapiriik.services.interchange import Activity, ActivityType 5 | 6 | from datetime import datetime, timedelta 7 | 8 | 9 | class InterchangeTests(TapiriikTestCase): 10 | 11 | def test_round_precise_time(self): 12 | ''' Some services might return really exact times, while others would round to the second - needs to be accounted for in hash algo ''' 13 | actA = Activity() 14 | actA.StartTime = datetime(1, 2, 3, 4, 5, 6, 7) 15 | actB = Activity() 16 | actB.StartTime = datetime(1, 2, 3, 4, 5, 6, 7) + timedelta(0, 0.1337) 17 | 18 | actA.CalculateUID() 19 | actB.CalculateUID() 20 | 21 | self.assertEqual(actA.UID, actB.UID) 22 | 23 | def test_constant_representation_rk(self): 24 | ''' ensures that all services' API clients are consistent through a simulated download->upload cycle ''' 25 | # runkeeper 26 | rkSvc = Service.FromID("runkeeper") 27 | act = TestTools.create_random_activity(rkSvc, rkSvc.SupportedActivities[0], withLaps=False) 28 | record = rkSvc._createUploadData(act) 29 | record["has_path"] = act.GPS # RK helpfully adds a "has_path" entry if we have waypoints. 30 | returnedAct = rkSvc._populateActivity(record) 31 | act.Name = None # RK doesn't have a "name" field, so it's fudged into the notes, but not really 32 | rkSvc._populateActivityWaypoints(record, returnedAct) 33 | # RK deliberately doesn't set timezone.. 34 | returnedAct.EnsureTZ() 35 | self.assertActivitiesEqual(returnedAct, act) 36 | 37 | # can't test Strava well this way, the upload and download formats are entirely different 38 | 39 | # can't test endomondo - upload data all constructed in upload function.. needs refactor? 40 | 41 | 42 | def test_activity_specificity_resolution(self): 43 | # Mountain biking is more specific than just cycling 44 | self.assertEqual(ActivityType.PickMostSpecific([ActivityType.Cycling, ActivityType.MountainBiking]), ActivityType.MountainBiking) 45 | 46 | # But not once we mix in an unrelated activity - pick the first 47 | self.assertEqual(ActivityType.PickMostSpecific([ActivityType.Cycling, ActivityType.MountainBiking, ActivityType.Swimming]), ActivityType.Cycling) 48 | 49 | # Duplicates 50 | self.assertEqual(ActivityType.PickMostSpecific([ActivityType.Cycling, ActivityType.MountainBiking, ActivityType.MountainBiking]), ActivityType.MountainBiking) 51 | 52 | # One 53 | self.assertEqual(ActivityType.PickMostSpecific([ActivityType.MountainBiking]), ActivityType.MountainBiking) 54 | 55 | # With None 56 | self.assertEqual(ActivityType.PickMostSpecific([None, ActivityType.MountainBiking]), ActivityType.MountainBiking) 57 | 58 | # All None 59 | self.assertEqual(ActivityType.PickMostSpecific([None, None]), ActivityType.Other) 60 | 61 | # Never pick 'Other' given a better option 62 | self.assertEqual(ActivityType.PickMostSpecific([ActivityType.Other, ActivityType.MountainBiking]), ActivityType.MountainBiking) 63 | 64 | # Normal w/ Other + None 65 | self.assertEqual(ActivityType.PickMostSpecific([ActivityType.Other, ActivityType.Cycling, None, ActivityType.MountainBiking]), ActivityType.MountainBiking) 66 | -------------------------------------------------------------------------------- /tapiriik/testing/tcx.py: -------------------------------------------------------------------------------- 1 | from tapiriik.testing.testtools import TestTools, TapiriikTestCase 2 | from tapiriik.services.tcx import TCXIO 3 | 4 | import os 5 | 6 | class TCXTests(TapiriikTestCase): 7 | def test_constant_representation(self): 8 | ''' ensures that tcx import/export is symetric ''' 9 | script_dir = os.path.dirname(__file__) 10 | rel_path = "data/test1.tcx" 11 | source_file_path = os.path.join(script_dir, rel_path) 12 | with open(source_file_path, 'r') as testfile: 13 | data = testfile.read() 14 | 15 | act = TCXIO.Parse(data.encode('utf-8')) 16 | act.PrerenderedFormats.clear() 17 | new_data = TCXIO.Dump(act) 18 | act2 = TCXIO.Parse(new_data.encode('utf-8')) 19 | rel_path = "data/output1.tcx" 20 | new_file_path = os.path.join(script_dir, rel_path) 21 | with open(new_file_path, "w") as new_file: 22 | new_file.write(new_data) 23 | 24 | self.assertActivitiesEqual(act2, act) 25 | 26 | def test_garmin_tcx_export(self): 27 | ''' ensures that tcx exported from Garmin Connect can be correctly parsed ''' 28 | script_dir = os.path.dirname(__file__) 29 | rel_path = "data/garmin_parse_1.tcx" 30 | source_file_path = os.path.join(script_dir, rel_path) 31 | with open(source_file_path, 'r') as testfile: 32 | data = testfile.read() 33 | 34 | act = TCXIO.Parse(data.encode('utf-8')) 35 | act.PrerenderedFormats.clear() 36 | new_data = TCXIO.Dump(act) 37 | act2 = TCXIO.Parse(new_data.encode('utf-8')) 38 | rel_path = "data/output2.tcx" 39 | new_file_path = os.path.join(script_dir, rel_path) 40 | with open(new_file_path, "w") as new_file: 41 | new_file.write(new_data) 42 | 43 | self.assertActivitiesEqual(act2, act) -------------------------------------------------------------------------------- /tapiriik/web/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/__init__.py -------------------------------------------------------------------------------- /tapiriik/web/context_processors.py: -------------------------------------------------------------------------------- 1 | from tapiriik.services import Service 2 | from tapiriik.auth import User 3 | from tapiriik.sync import Sync 4 | from tapiriik.settings import SITE_VER, PP_WEBSCR, PP_BUTTON_ID, SOFT_LAUNCH_SERVICES, DISABLED_SERVICES, WITHDRAWN_SERVICES, CELEBRATION_MODES 5 | from tapiriik.database import db 6 | from datetime import datetime 7 | import json 8 | 9 | 10 | def providers(req): 11 | return {"service_providers": Service.List()} 12 | 13 | def config(req): 14 | in_diagnostics = "diagnostics" in req.path 15 | return {"config": {"minimumSyncInterval": Sync.MinimumSyncInterval.seconds, "siteVer": SITE_VER, "pp": {"url": PP_WEBSCR, "buttonId": PP_BUTTON_ID}, "soft_launch": SOFT_LAUNCH_SERVICES, "disabled_services": DISABLED_SERVICES, "withdrawn_services": WITHDRAWN_SERVICES, "in_diagnostics": in_diagnostics}, "hidden_infotips": req.COOKIES.get("infotip_hide", None)} 16 | 17 | def user(req): 18 | return {"user":req.user} 19 | 20 | def js_bridge(req): 21 | serviceInfo = {} 22 | 23 | for svc in Service.List(): 24 | if svc.ID in WITHDRAWN_SERVICES: 25 | continue 26 | if req.user is not None: 27 | svcRec = User.GetConnectionRecord(req.user, svc.ID) # maybe make the auth handler do this only once? 28 | else: 29 | svcRec = None 30 | info = { 31 | "DisplayName": svc.DisplayName, 32 | "DisplayAbbreviation": svc.DisplayAbbreviation, 33 | "AuthenticationType": svc.AuthenticationType, 34 | "UsesExtendedAuth": svc.RequiresExtendedAuthorizationDetails, 35 | "AuthorizationURL": svc.UserAuthorizationURL, 36 | "NoFrame": svc.AuthenticationNoFrame, 37 | "ReceivesActivities": svc.ReceivesActivities, 38 | "Configurable": svc.Configurable, 39 | "RequiresConfiguration": False # by default 40 | } 41 | if svcRec: 42 | if svc.Configurable: 43 | if svc.ID == "dropbox": # dirty hack alert, but better than dumping the auth details in their entirety 44 | info["AccessLevel"] = "full" if svcRec.Authorization["Full"] else "normal" 45 | info["RequiresConfiguration"] = svc.RequiresConfiguration(svcRec) 46 | info["Config"] = svcRec.GetConfiguration() 47 | info["HasExtendedAuth"] = svcRec.HasExtendedAuthorizationDetails() 48 | info["PersistedExtendedAuth"] = svcRec.HasExtendedAuthorizationDetails(persisted_only=True) 49 | info["ExternalID"] = svcRec.ExternalID 50 | info["BlockFlowTo"] = [] 51 | info["Connected"] = svcRec is not None 52 | serviceInfo[svc.ID] = info 53 | if req.user is not None: 54 | flowExc = User.GetFlowExceptions(req.user) 55 | for exc in flowExc: 56 | if exc["Source"]["Service"] not in serviceInfo or exc["Target"]["Service"] not in serviceInfo: 57 | continue # Withdrawn services 58 | if "ExternalID" in serviceInfo[exc["Source"]["Service"]] and exc["Source"]["ExternalID"] != serviceInfo[exc["Source"]["Service"]]["ExternalID"]: 59 | continue # this is an old exception for a different connection 60 | if "ExternalID" in serviceInfo[exc["Target"]["Service"]] and exc["Target"]["ExternalID"] != serviceInfo[exc["Target"]["Service"]]["ExternalID"]: 61 | continue # same as above 62 | serviceInfo[exc["Source"]["Service"]]["BlockFlowTo"].append(exc["Target"]["Service"]) 63 | return {"js_bridge_serviceinfo": json.dumps(serviceInfo)} 64 | 65 | 66 | def stats(req): 67 | return {"stats": db.stats.find_one()} 68 | 69 | def celebration_mode(req): 70 | active_config = None 71 | now = datetime.now() 72 | for date_range, config in CELEBRATION_MODES.items(): 73 | if date_range[0] <= now and date_range[1] >= now: 74 | active_config = config 75 | break 76 | return {"celebration_mode": active_config} 77 | -------------------------------------------------------------------------------- /tapiriik/web/email.py: -------------------------------------------------------------------------------- 1 | from django.template.loader import get_template 2 | from django.template import Context 3 | from django.core.mail import EmailMultiAlternatives 4 | from django.conf import settings 5 | 6 | def generate_message_from_template(template, context): 7 | context["STATIC_URL"] = settings.STATIC_URL 8 | # Mandrill is set up to inline the CSS and generate a plaintext copy. 9 | html_message = get_template(template).render(Context(context)).strip() 10 | context["plaintext"] = True 11 | plaintext_message = get_template(template).render(Context(context)).strip() 12 | return html_message, plaintext_message 13 | 14 | def send_email(recipient_list, subject, html_message, plaintext_message=None): 15 | if type(recipient_list) is not list: 16 | recipient_list = [recipient_list] 17 | 18 | email = EmailMultiAlternatives(subject=subject, body=plaintext_message, from_email="exercisync ", to=recipient_list, headers={"Reply-To": "help@exercisync.com"}) 19 | email.attach_alternative(html_message, "text/html") 20 | email.send() -------------------------------------------------------------------------------- /tapiriik/web/models.py: -------------------------------------------------------------------------------- 1 | from django.db import models 2 | 3 | # Create your models here. 4 | -------------------------------------------------------------------------------- /tapiriik/web/startup.py: -------------------------------------------------------------------------------- 1 | from django.core.exceptions import MiddlewareNotUsed 2 | import tapiriik.settings 3 | import subprocess 4 | 5 | 6 | class ServiceWebStartup: 7 | def __init__(self): 8 | from tapiriik.services import Service 9 | Service.WebInit() 10 | raise MiddlewareNotUsed 11 | 12 | 13 | class Startup: 14 | def __init__(self): 15 | tapiriik.settings.SITE_VER = subprocess.Popen(["git", "rev-parse", "HEAD"], stdout=subprocess.PIPE).communicate()[0].strip() 16 | raise MiddlewareNotUsed 17 | -------------------------------------------------------------------------------- /tapiriik/web/static/css/diagnostics.css: -------------------------------------------------------------------------------- 1 | 2 | .contentOuter { 3 | width:auto; 4 | height:auto; 5 | display:block; 6 | position: relative; 7 | padding:0; 8 | margin:0; 9 | } 10 | 11 | .contentWrap { 12 | display: block; 13 | } 14 | 15 | .content { 16 | } 17 | 18 | .logo { 19 | text-align: left; 20 | margin-left:30px; 21 | margin-top:-120px; 22 | width:auto; 23 | } 24 | 25 | .logo .sub { 26 | display: none; 27 | } 28 | 29 | .logoPad { 30 | margin-top:120px; 31 | } 32 | 33 | .contentOuterBorder { 34 | margin:20px; 35 | left:0; 36 | right:0; 37 | width:auto; 38 | } 39 | 40 | .footers { 41 | display: none; 42 | } -------------------------------------------------------------------------------- /tapiriik/web/static/errors/upgrade.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | exercisync is offline 6 | 7 | 8 | 9 | 10 | 11 |
12 |
13 |
14 | 18 |
19 |
20 |
21 | 22 | 23 |
24 |
25 |
26 |
27 |
28 |
29 |
30 |
31 | 32 | -------------------------------------------------------------------------------- /tapiriik/web/static/img/android-chrome-192x192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/android-chrome-192x192.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/android-chrome-512x512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/android-chrome-512x512.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/apple-touch-icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/apple-touch-icon.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/art/mapmyfitness-logo.ai: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/art/mapmyfitness-logo.ai -------------------------------------------------------------------------------- /tapiriik/web/static/img/connector-dot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/connector-dot.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/email/corner_bl.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/email/corner_bl.gif -------------------------------------------------------------------------------- /tapiriik/web/static/img/email/corner_br.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/email/corner_br.gif -------------------------------------------------------------------------------- /tapiriik/web/static/img/email/corner_tl.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/email/corner_tl.gif -------------------------------------------------------------------------------- /tapiriik/web/static/img/email/corner_tr.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/email/corner_tr.gif -------------------------------------------------------------------------------- /tapiriik/web/static/img/email/tapiriik-wordmark.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/email/tapiriik-wordmark.gif -------------------------------------------------------------------------------- /tapiriik/web/static/img/email/trans.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/email/trans.gif -------------------------------------------------------------------------------- /tapiriik/web/static/img/favicon-16x16.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/favicon-16x16.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/favicon-32x32.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/favicon-32x32.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/favicon.ico -------------------------------------------------------------------------------- /tapiriik/web/static/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/favicon.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/garminconnect_useradd.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/garminconnect_useradd.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/link.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/link.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/mstile-150x150.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/mstile-150x150.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/pp-logo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/pp-logo.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/privacy-cached.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/privacy-cached.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/privacy-no.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/privacy-no.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/privacy-optin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/privacy-optin.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/privacy-yes.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/privacy-yes.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/rule-add.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 8 | 10 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /tapiriik/web/static/img/rule-delete.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 41 | -------------------------------------------------------------------------------- /tapiriik/web/static/img/safari-pinned-tab.svg: -------------------------------------------------------------------------------- 1 | 2 | 4 | 7 | 8 | Created by potrace 1.11, written by Peter Selinger 2001-2013 9 | 10 | 12 | 63 | 64 | 65 | -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/aerobia.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/aerobia.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/aerobia_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/aerobia_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/beginnertriathlete.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/beginnertriathlete.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/beginnertriathlete_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/beginnertriathlete_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/decathloncoach.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/decathloncoach.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/decathloncoach_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/decathloncoach_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/dropbox.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/dropbox.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/dropbox_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/dropbox_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/endomondo.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/endomondo.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/endomondo_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/endomondo_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/garminconnect.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/garminconnect.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/garminconnect_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/garminconnect_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/localexporter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/localexporter.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/localexporter_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/localexporter_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/motivato.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/motivato.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/motivato_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/motivato_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/nikeplus.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/nikeplus.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/nikeplus_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/nikeplus_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/polarflow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/polarflow.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/polarflow_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/polarflow_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/polarpersonaltrainer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/polarpersonaltrainer.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/polarpersonaltrainer_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/polarpersonaltrainer_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/pulsstory.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/pulsstory.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/pulsstory_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/pulsstory_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/runkeeper.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/runkeeper.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/runkeeper_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/runkeeper_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/runsense.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/runsense.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/runsense_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/runsense_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/rwgps.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/rwgps.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/rwgps_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/rwgps_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/setio.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/setio.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/setio_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/setio_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/singletracker.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/singletracker.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/singletracker_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/singletracker_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/smashrun.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/smashrun.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/smashrun_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/smashrun_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/sporttracks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/sporttracks.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/sporttracks_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/sporttracks_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/strava.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/strava.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/strava_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/strava_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/trainasone.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/trainasone.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/trainasone_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/trainasone_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/trainerroad.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/trainerroad.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/trainerroad_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/trainerroad_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/trainingpeaks.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/trainingpeaks.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/trainingpeaks_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/trainingpeaks_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/velohero.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/velohero.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/services/velohero_l.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/services/velohero_l.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/snow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/snow.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/sync-arrow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/sync-arrow.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/sync-fail.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/sync-fail.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/sync-go.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/sync-go.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/sync-info.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/sync-info.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/sync-ok.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/sync-ok.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/sync-pause.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/sync-pause.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/sync-reauth.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/sync-reauth.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/sync-settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/sync-settings.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/sync-spin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/sync-spin.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/tapiriik-arabic.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/tapiriik-arabic.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/tapiriik-hebrew.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/tapiriik-hebrew.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/tapiriik-hindi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/tapiriik-hindi.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/tapiriik-inuktitut.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/tapiriik-inuktitut.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/tapiriik-punjabi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/tapiriik-punjabi.png -------------------------------------------------------------------------------- /tapiriik/web/static/img/trainingpeaks_download: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/trainingpeaks_download -------------------------------------------------------------------------------- /tapiriik/web/static/img/unlink.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/static/img/unlink.png -------------------------------------------------------------------------------- /tapiriik/web/static/js/datepicker/pikaday-1.6.1.min.css: -------------------------------------------------------------------------------- 1 | @charset "UTF-8";/*! 2 | * Pikaday 3 | * Copyright © 2014 David Bushell | BSD & MIT license | http://dbushell.com/ 4 | */.pika-single{z-index:9999;display:block;position:relative;color:#333;background:#fff;border:1px solid #ccc;border-bottom-color:#bbb;font-family:"Helvetica Neue",Helvetica,Arial,sans-serif}.pika-single:after,.pika-single:before{content:" ";display:table}.pika-single:after{clear:both}.pika-single.is-hidden{display:none}.pika-single.is-bound{position:absolute;box-shadow:0 5px 15px -5px rgba(0,0,0,.5)}.pika-lendar{float:left;width:240px;margin:8px}.pika-title{position:relative;text-align:center}.pika-label{display:inline-block;position:relative;z-index:9999;overflow:hidden;margin:0;padding:5px 3px;font-size:14px;line-height:20px;font-weight:700;background-color:#fff}.pika-title select{cursor:pointer;position:absolute;z-index:9998;margin:0;left:0;top:5px;opacity:0}.pika-next,.pika-prev{display:block;cursor:pointer;position:relative;outline:0;border:0;padding:0;width:20px;height:30px;text-indent:20px;white-space:nowrap;overflow:hidden;background-color:transparent;background-position:center center;background-repeat:no-repeat;background-size:75% 75%;opacity:.5}.pika-next:hover,.pika-prev:hover{opacity:1}.is-rtl .pika-next,.pika-prev{float:left;background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAAUklEQVR42u3VMQoAIBADQf8Pgj+OD9hG2CtONJB2ymQkKe0HbwAP0xucDiQWARITIDEBEnMgMQ8S8+AqBIl6kKgHiXqQqAeJepBo/z38J/U0uAHlaBkBl9I4GwAAAABJRU5ErkJggg==)}.is-rtl .pika-prev,.pika-next{float:right;background-image:url(data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAABQAAAAeCAYAAAAsEj5rAAAAU0lEQVR42u3VOwoAMAgE0dwfAnNjU26bYkBCFGwfiL9VVWoO+BJ4Gf3gtsEKKoFBNTCoCAYVwaAiGNQGMUHMkjGbgjk2mIONuXo0nC8XnCf1JXgArVIZAQh5TKYAAAAASUVORK5CYII=)}.pika-next.is-disabled,.pika-prev.is-disabled{cursor:default;opacity:.2}.pika-select{display:inline-block}.pika-table{width:100%;border-collapse:collapse;border-spacing:0;border:0}.pika-table td,.pika-table th{width:14.285714285714286%;padding:0}.pika-table th{color:#999;font-size:12px;line-height:25px;font-weight:700;text-align:center}.pika-button{cursor:pointer;display:block;box-sizing:border-box;-moz-box-sizing:border-box;outline:0;border:0;margin:0;width:100%;padding:5px;color:#666;font-size:12px;line-height:15px;text-align:right;background:#f5f5f5}.pika-week{font-size:11px;color:#999}.is-today .pika-button{color:#3af;font-weight:700}.has-event .pika-button,.is-selected .pika-button{color:#fff;font-weight:700;background:#3af;box-shadow:inset 0 1px 3px #178fe5;border-radius:3px}.has-event .pika-button{background:#005da9;box-shadow:inset 0 1px 3px #0076c9}.is-disabled .pika-button,.is-inrange .pika-button{background:#d5e9f7}.is-startrange .pika-button{color:#fff;background:#6cb31d;box-shadow:none;border-radius:3px}.is-endrange .pika-button{color:#fff;background:#3af;box-shadow:none;border-radius:3px}.is-disabled .pika-button{pointer-events:none;cursor:default;color:#999;opacity:.3}.is-outside-current-month .pika-button{color:#999;opacity:.3}.is-selection-disabled{pointer-events:none;cursor:default}.pika-button:hover,.pika-row.pick-whole-week:hover .pika-button{color:#fff;background:#ff8000;box-shadow:none;border-radius:3px}.pika-table abbr{border-bottom:none;cursor:help} 5 | /*# sourceMappingURL=pikaday.min.css.map */ -------------------------------------------------------------------------------- /tapiriik/web/static/site.webmanifest: -------------------------------------------------------------------------------- 1 | { 2 | "name": "", 3 | "short_name": "", 4 | "icons": [ 5 | { 6 | "src": "/static/img/android-chrome-192x192.png", 7 | "sizes": "192x192", 8 | "type": "image/png" 9 | }, 10 | { 11 | "src": "/static/img/android-chrome-512x512.png", 12 | "sizes": "512x512", 13 | "type": "image/png" 14 | } 15 | ], 16 | "theme_color": "#ffffff", 17 | "background_color": "#ffffff", 18 | "display": "standalone" 19 | } 20 | -------------------------------------------------------------------------------- /tapiriik/web/templates/activities-dashboard.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load i18n %} 3 | {% load services %} 4 | {% load users %} 5 | {% load displayutils %} 6 | {% block title %}activities{% endblock %} 7 | 8 | {% block content %} 9 |

{% trans "Your activities" %}

10 | {% trans "Show only activities with sync errors:" %} 11 |
12 | 13 | 14 | 15 | 16 | 17 | 18 | 23 | 24 | 25 | 26 | 37 | 38 |
 {[ svc.DisplayAbbreviation ]}
19 | {[ activity.StartTime|date ]} {[ activity.Name ]} 20 | Private{% trans "Stationary" %} 21 | {[ activity.Type ]} 22 | {% trans "Present" %}
27 | 28 | 29 |

{% trans "This activity was not synchronized to the following services:" %}

30 |
    31 |
  • 32 | {[ DisplayNameByService(absence.Service) ]}: 33 |
  • 34 |
35 |
36 |
39 |

{% trans "exercisync doesn't know about any activities in your accounts" %}

{% trans "Have you synchronized lately?" %}
40 |

{% trans "Loading..." %}

41 |

{% trans "If your account is currently synchronizing, those activities will appear here once the synchronization completes. All dates shown are in UTC." %}

42 |
43 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/auth/disconnect.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load i18n %} 3 | {% block title %}disconnect{% endblock %} 4 | {% block content %} 5 |

Disconnect {{ service.DisplayName }}?

6 |
7 | {% csrf_token %} 8 |
9 |
10 |
11 |

12 | {% trans "(no data will be deleted - just no more synchronization will happen)" %} 13 |

14 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/auth/login.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load i18n %} 3 | {% block title %}login{% endblock %} 4 | {% block content %} 5 | {% if res != None and res == false %} 6 |
{% trans "There was a problem logging you into" %} {{ service.DisplayName }}
7 | {% endif %} 8 | {% trans "You're logging into" %} {{ serviceid }} 9 |
10 | 11 | 12 | 13 | 14 | 15 | {% csrf_token %} 16 |
17 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/config/aerobia.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load services %} 3 | {% load users %} 4 | {% load displayutils %} 5 | {% load render_bundle from webpack_loader %} 6 | 7 | {% block title %}Configure Aerobia{% endblock %} 8 | {% block content %} 9 |
10 | 14 | {% render_bundle 'exercisyncApp' %} 15 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/config/dropbox.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% block title %}configure Dropbox{% endblock %} 3 | {% block content %} 4 |

Dropbox configuration

5 |
6 | {% csrf_token %} 7 | {{ form.as_p }} 8 | 9 |
10 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/config/localexporter.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load services %} 3 | {% load users %} 4 | {% load displayutils %} 5 | {% load render_bundle from webpack_loader %} 6 | 7 | {% block title %}Configure Local Exporter{% endblock %} 8 | {% block content %} 9 |
10 | 14 | {% render_bundle 'exercisyncApp' %} 15 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/diag/error.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load displayutils %} 3 | {% block title %}error {{ error.value.exemplar }}{% endblock %} 4 | {% block content %} 5 |
6 |

« Back to dashboard

7 |

{{ error.value.exemplar }} ({{ error|dict_get:'_id'|dict_get:'service' }})

8 |

Affected users:

9 | {% for user in affected_user_ids %} 10 | {{ user }} 11 | {% endfor %} 12 |
13 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/diag/error_error_not_found.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load displayutils %} 3 | {% block title %}diagnostics{% endblock %} 4 | {% block content %} 5 |

Error not found

6 |

(yay?)

7 |

« Back to dashboard

8 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/diag/error_user_not_found.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load displayutils %} 3 | {% block title %}diagnostics{% endblock %} 4 | {% block content %} 5 |

User not found

6 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/diag/errors.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load displayutils %} 3 | {% block title %}errors{% endblock %} 4 | {% block content %} 5 |
6 | 10 |
11 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/diag/graphs.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load displayutils %} 3 | {% block title %}graphs{% endblock %} 4 | {% block content %} 5 |
6 |
7 | 8 | 42 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/diag/login.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% block title %}diagnostics auth{% endblock %} 3 | {% block content %} 4 |
5 |

Super seecreet diagnostics dashboard login (ooooh! aaaaah!)

6 |
7 |
8 |
9 | 10 | {% csrf_token %} 11 |
12 |
13 | 14 | 15 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/diag/payments.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load displayutils %} 3 | {% block title %}payments{% endblock %} 4 | {% block content %} 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | {% for payment in payments %} 14 | 15 | 16 | 17 | 18 | 19 | 20 | {% endfor %} 21 |
TxnTimestampExpiryAssociated accounts
{{ payment.Txn }}{{ payment.Timestamp }}{{ payment.Expiry }}{% for account in payment.Accounts %}{{ account }} {% endfor %}
22 | 23 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/donation.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load displayutils %} 3 | 4 | 20 | -------------------------------------------------------------------------------- /tapiriik/web/templates/download.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load i18n %} 3 | {% load services %} 4 | {% load users %} 5 | {% load displayutils %} -------------------------------------------------------------------------------- /tapiriik/web/templates/email/data_download.html: -------------------------------------------------------------------------------- 1 | {% extends "email/template.html" %} 2 | {% load i18n %} 3 | {% block content %} 4 |

{% trans "Your data is ready!" %}

5 |

{% trans "Please, download your data by following " %}{% trans "this link." %}

6 | {% endblock %} 7 | {% block plaintextcontent %} 8 | {% trans "Your data is ready!" %} 9 | 10 | {% trans "Please, download your data here: " %} {{ url|safe }} 11 | 12 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/email/payment_confirm.html: -------------------------------------------------------------------------------- 1 | {% extends "email/template.html" %} 2 | {% load i18n %} 3 | {% block content %} 4 |

{% trans "You're good to go" %}

5 |

{% trans "Your payment has been processed and your tapiriik account is now set up for automatic synchronization. Of course, you can still visit " %}tapiriik.com {% trans "at any time to trigger an immediate synchronization." %}

6 | {% endblock %} 7 | {% block plaintextcontent %} 8 | {% trans "Your payment has been processed and your tapiriik account is now set up for automatic synchronization." %} 9 | 10 | {% trans "Of course, you can still visit {{ url|safe }} at any time to trigger an immediate synchronization." %} 11 | 12 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/email/payment_reclaim.html: -------------------------------------------------------------------------------- 1 | {% extends "email/template.html" %} 2 | {% load i18n %} 3 | {% block content %} 4 |

{% trans "Click here to reclaim your payment" %}

5 |

{% trans "Your payment will be reclaimed & associated with the services you are currently connected to, and any you connect in the future." %}

6 | {% endblock %} 7 | {% block plaintextcontent %} 8 | {% trans "Use the following link to to reclaim your payment" %}: 9 | 10 | {{ url|safe }} 11 | 12 | {% trans "Your payment will be reclaimed & transferred to the services you are currently connected to, and any you connect in the future." %} 13 | 14 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/email/payment_renew.html: -------------------------------------------------------------------------------- 1 | {% extends "email/template.html" %} 2 | {% load i18n %} 3 | {% block content %} 4 |

{% trans "It's been a good" %} {{ subscription_days }} {% trans "days" %}!

5 |

{% trans "In the past" %} {{ subscription_fuzzy_time }} {% trans "or so, tapiriik has kept your" %} {{ account_list }} {% trans "in sync, automatically transferring" %} {% if distance %}{% trans "over" %} {{ distance }} {% trans "km of activities" %}{% else %}{% trans "your fitness activities night and day" %}{% endif %}.

6 | 7 |

{% trans "Want to continue automatic synchronization?" %}

8 | 9 |

{% trans "If you want to keep automatic synchronization for another year, visit " %}tapiriik.com, {% trans "connect to one of your accounts, and click the "Automatic Synchronization" link." %}

10 | 11 |

{% trans "Don't worry - you won't automatically be billed." %}

12 |

{% trans "(and if you were dissatisfied with your experience, please reply to this email to let me know why!)" %}

13 | 14 | {% endblock %} 15 | {% block plaintextcontent %} 16 | {% trans "It's been a good" %} {{ subscription_days }} {% trans "days" %}! 17 | 18 | {% trans "In the past" %} {{ subscription_fuzzy_time }} {% trans "or so, tapiriik has kept your" %} {{ account_list }} {% trans "in sync, automatically monitoring and transferring" %} {% if distance %}{% trans "over" %} {{ distance }} {% trans "km of your activities" %}{% else %}{% trans "your fitness activities night and day" %}{% endif %}. 19 | 20 | {% trans "If you want to keep automatic synchronization for another year, visit https://tapiriik.com then connect to one of your accounts, and click the "Automatic Synchronization" link." %} 21 | 22 | {% trans "Don't worry - you won't automatically be billed. If you were dissatisfied with your experience, please reply to this email to let me know why!)" %} 23 | 24 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/email/template.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% if not plaintext %} 3 | 4 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | 74 | 75 | 76 | 77 | 78 | 79 | 80 | 83 | 84 | 85 | 86 | 87 | 88 | 89 | 90 | 91 | 92 | 93 | 94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | 102 | 103 | 104 | 105 | 106 |
  107 | 108 |
 
      56 |
tapiriik
57 |
     
 
  
  
   
81 | {% block content %}{% endblock %} 82 |
   
 
  
 
   Questions? Email me or reply to this email.
109 | {% else %}{% block plaintextcontent %}{% endblock %} 110 | 111 | 112 | Questions? Email me at help@exercisync.com{% endif %} -------------------------------------------------------------------------------- /tapiriik/web/templates/js-bridge.js: -------------------------------------------------------------------------------- 1 | {% load users %} 2 | {% load displayutils %} 3 | tapiriik.StaticURL = "{{ STATIC_URL }}"; 4 | tapiriik.SiteVer = "{{ config.siteVer|slice:":7" }}"; 5 | tapiriik.ServiceInfo = {{ js_bridge_serviceinfo|safe }} 6 | tapiriik.MinimumSyncInterval = {{ config.minimumSyncInterval }}; 7 | {% if user %}tapiriik.User = { 8 | ConnectedServicesCount: {{ user.ConnectedServices|length }}, 9 | ID: "{{ user|dict_get:'_id' }}", 10 | Timezone: "{{ user|dict_get:'Timezone' }}", 11 | Substitute: {{ user.Substitute|lower }}, 12 | AutoSyncActive: {{ user|has_active_payment|lower }}, 13 | Config: {{ user.Config|json|safe }} 14 | }; 15 | {% endif %} -------------------------------------------------------------------------------- /tapiriik/web/templates/oauth-failure.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load i18n %} 3 | {% block title %}login failed{% endblock %} 4 | {% block content %} 5 |

{% trans "There was a problem logging you into" %} {{ service.DisplayName }}:
6 | {{ error }}

7 |

{% trans "Try logging in again - it might just be a temporary problem connecting to" %} {{ service.DisplayName }}. {% trans "If that doesn't help, try connecting this account using a different browser or device." %}

8 | {% endblock %} 9 | -------------------------------------------------------------------------------- /tapiriik/web/templates/oauth-return.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /tapiriik/web/templates/payment.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | {% load displayutils %} 3 | 26 | Automatic synchronization 27 | 71 | -------------------------------------------------------------------------------- /tapiriik/web/templates/payments/claim.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load i18n %} 3 | {% block title %}payment claim{% endblock %} 4 | {% block content %} 5 |

{% trans "Claim your payment" %}

6 | 7 |
8 | {% if err == True %} 9 |
{% trans "There was a problem reclaiming your payment - please contact me" %}
10 | {% endif %} 11 |

{% trans "Your payment will be reassociated with the accounts you a currently connected to" %}.

12 |
13 | {% csrf_token %} 14 | 15 | 16 |
17 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/payments/claim_return_fail.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load i18n %} 3 | {% block title %}payment reclaim error{% endblock %} 4 | {% block content %} 5 |

{% trans "Invalid payment claim code" %}

6 | {% trans "Please contact me if you're confused." %} 7 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/payments/confirmed.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load i18n %} 3 | {% block title %}payment processed{% endblock %} 4 | {% block content %} 5 |

{% trans "Thanks" %}!

6 |

{% trans "Your exercisync account is now set up for automatic synchronization. You can " %}{% trans "head back to the dashboard" %} {% trans "or close this window" %}.

7 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/payments/return.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load i18n %} 3 | {% block title %}payment return{% endblock %} 4 | {% block head %}{% endblock %} 5 | {% block content %} 6 |

{% trans "Please hold" %}...

7 |

({% trans "we're waiting for PayPal" %})

8 |

{% trans "If you made an eCheque payment" %}, {% trans "your account will be credited once the payment clears (so you can" %} {% trans "return to the dashboard" %} {% trans "right away" %}).

9 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/recent-sync-activity-block.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |
3 | 4 | 5 | 8 | 9 | 10 | 13 | 16 | 17 | 18 | 21 | 22 |
6 | {% trans "Recently Synchronized Activities" %} 7 |
11 | {[ activity.StartTime|date ]} {[ activity.Name ]} 12 | 14 | {[ activity.Type ]} 15 |
19 | {% trans "All Activities" %} » 20 |
23 |
-------------------------------------------------------------------------------- /tapiriik/web/templates/rollback.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load i18n %} 3 | {% load services %} 4 | {% load users %} 5 | {% load displayutils %} 6 | {% block title %}roll back activities{% endblock %} 7 | 8 | {% block content %} 9 |

Roll back all activities uploaded by exercisync

10 |

READ ALL OF THIS.

11 |
12 |

This page lets you roll back all activities exercisync has uploaded to most of your accounts

13 |

To emphasize, performing a roll-back will delete each and every activity that exercisync has ever uploaded, where possible. Gone with those activities will be any associated comments, pictures, kudos, KOMs, etc. - all deleted permanently.

14 | 15 |

If you deleted any of the original copies after they were synced, those activities will be lost forever.

16 | 17 |

No other activities will be deleted. exercisync tracks the unique identifiers assigned to each of its uploads by the remote service - it's these unique identifiers that are used to perform the rollback.

18 | 19 |

If the above sounds too scary, you can use the dry-run option to retrieve a list of links to the activities which would be deleted. You can then manually delete the undesired activities.

20 |
21 | 22 |
23 |

These services support rollback

24 | {% for provider in service_providers %} 25 | {% if provider.SupportsActivityDeletion %} 26 | {{ provider.DisplayName }} 27 | {% endif %} 28 | {% endfor %} 29 |
30 |
31 |

These services don't support rollback

32 | {% for provider in service_providers %} 33 | {% if not provider.SupportsActivityDeletion %} 34 | {{ provider.DisplayName }} 35 | {% endif %} 36 | {% endfor %} 37 |

Why not? Not all services offer a method to automatically delete activities. You can still manually delete activities on these services using the activity list below.

38 |
39 | 40 |
41 | 42 |

Fetching list (it'll take a bit)

43 |
44 |

exercisync-originating activities:

45 |

(if some activities aren't listed, make sure you're connected to all the accounts you wish to roll back. Roll-back is not available for activities uploaded prior to January 18th 2014)

46 |
47 |

{[ DisplayNameByService(svc) ]}

48 |
49 | {[ svc ]}-{[ upload ]} See on site » 50 | 51 | {[ task.DeletionStatus[svc][upload] ? "deleted" : "delete failed" ]} 52 | 53 |
54 |
55 | 56 | 57 |
58 |

Rollback scheduled - check list above for status

59 |

Closing your browser will not cancel the process - however you must remain on this page to receive status updates.

60 |

Deletions may fail if the activity was already deleted, or if exercisync no longer has access to your account. You can use this tool as many times as required.

61 |
62 |
63 |
64 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/service-blockingexception.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 |
3 |

Error with {{ provider.DisplayName }}

4 |

5 | {% if exception.UserException.Type == "full" %} 6 | There wasn't enough room in your {{ provider.DisplayName }} account to synchronize activities. Once you've cleared up some space, hit the button below to re-synchronize your {{ provider.DisplayName }} account. 7 | {% elif exception.UserException.Type == "auth" %} 8 | There was a problem accessing your {{ provider.DisplayName }} account. Use the button below to reauthorize exercisync to access your account. Garmin Connect User? You may need to sign in directly on Garmin Connect and reset your password to meet new security requirements - then come back here and try again. 9 | {% elif exception.UserException.Type == "locked" or exception.UserException.Type == "reset_password" %} 10 | There was a problem accessing your {{ provider.DisplayName }} account. Visit {{ provider.DisplayName }} directly and make sure you can sign in with your username and password - there may be a problem with your account that needs to be resolved. Then, use the button below to reauthorize exercisync to access your account. 11 | {% elif exception.UserException.Type == "renew_password" %} 12 | There was a problem signing into your {{ provider.DisplayName }} account. You may have changed your password on {{ provider.DisplayName }} recently, or you need to sign into your {{ provider.DisplayName }} account directly and reset your password to meet {{ provider.DisplayName }}'s new security requirements. Once you've done so, use the button below to update your password and resume synchronization. 13 | {% elif exception.UserException.Type == "expired" %} 14 | It looks like your {{ provider.DisplayName }} account has expired. Once it's back in action, use the button below to re-synchronize your {{ provider.DisplayName }} account. 15 | {% elif exception.UserException.Type == "gc_upload_consent" %} 16 | You must grant Garmin Connect permission to accept data uploads. Once you've done so, use the button below to re-synchronize your {{ provider.DisplayName }} account. 17 | {% endif %} 18 |

19 | 32 |
-------------------------------------------------------------------------------- /tapiriik/web/templates/service-button.html: -------------------------------------------------------------------------------- 1 | {% load services %} 2 | {% load i18n %} 3 | {% with extauth=provider.RequiresExtendedAuthorizationDetails hasextauth=connection.HasExtendedAuthorizationDetails %} 4 |
5 |
6 | {% if not user or provider.ID not in user.ConnectedServices|svc_ids %} 7 | {% if provider.ID not in config.disabled_services %} 8 | 9 | {% endif %} 10 | {% endif %} 11 |
12 |
{{ provider.DisplayName }}{% trans "Sync with" %} {{ provider.DisplayName }}
13 |
14 | {% if not user or provider.ID not in user.ConnectedServices|svc_ids %} 15 | {% if provider.ID not in config.disabled_services %} 16 |
17 | {% endif %} 18 | {% endif %} 19 |
20 |
21 | {% if provider.ID in config.disabled_services %} 22 | Offline 23 | {% else %} 24 | {% if provider.ID in user.ConnectedServices|svc_ids %} 25 |
26 | {% if extauth and not hasextauth %} 27 | {% trans "Paused" %} 28 | {% else %} 29 | {% trans "Connected" %} 30 | {% endif %} 31 |
32 | {% if user.ConnectedServices|length > 1 %} 33 | {% if extauth and not hasextauth %} 34 | 37 | {% endif %} 38 | 41 | {% else %} 42 | 45 | {% endif %} 46 | {% else %} 47 | 50 | {% endif %} 51 | {% endif %} 52 |
53 |
54 | 55 |
56 |
57 | {% endwith %} -------------------------------------------------------------------------------- /tapiriik/web/templates/settings-block.html: -------------------------------------------------------------------------------- 1 | {% load i18n %} 2 | -------------------------------------------------------------------------------- /tapiriik/web/templates/settings.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load i18n %} 3 | {% load services %} 4 | {% load users %} 5 | {% load displayutils %} 6 | {% block title %}Advanced settings{% endblock %} 7 | 8 | {% block content %} 9 |
10 |

{% trans "Advanced settings" %}

11 |
12 | {% csrf_token %} 13 | 14 | 15 | {% for connection in user.ConnectedServices|svc_populate_conns %} 16 | {% with svc=connection.Service %} 17 | 18 | {% endwith %} 19 | {% endfor %} 20 | 21 | 22 | {% for key, setting in settings.items %} 23 | 24 | 37 | {% endfor %} 38 | 39 | 40 | {% endfor %} 41 | 42 | 43 | {% for connection in user.ConnectedServices %} 44 | 45 | {% endfor %} 46 | 47 |
{{ svc.DisplayName }}
25 |

{{ setting.Title }}

26 |

{{ setting.Description }}

27 | {% for connection in user.ConnectedServices|svc_populate_conns %} 28 |
29 | {% if connection.Service.ID in setting.Available or not setting.Available %} 30 | {% if setting.Field == "checkbox" %} 31 | 32 | {% else %} 33 | 34 | {% endif %} 35 | {% endif %} 36 |
48 |
49 | 50 |
51 |
52 |
53 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/site-iframe.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 14 | 15 | 16 | {% block content %}{% endblock %} 17 | 18 | -------------------------------------------------------------------------------- /tapiriik/web/templates/static/contact.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load i18n %} 3 | {% load displayutils %} 4 | {% block title %}contact{% endblock %} 5 | {% block content %} 6 |
7 |

{% trans "For system status updates" %}

8 | {% trans "exercisync group on aerobia.ru" %} 9 |
10 |
11 |

{% trans "For help with your account" %}

12 |

{% trans "If you are having issues with your account" %}, {% trans "please check" %} {% trans "the activity dashboard" %} - {% trans "it'll do its best to tell you what went wrong" %}.

13 | help@exercisync.com 14 |

{% trans "If you're dissatisfied with the service and want a refund, make sure to mention that in the subject line so I can respond quickly." %}

15 |

{% trans "If you are making an enquiry regarding the European Union's General Data Protection Regulation, please mention "GDPR" in the subject line." %}

16 |
17 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/static/credits.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load i18n %} 3 | {% block title %}credits{% endblock %} 4 | {% block content %} 5 |

{% trans "Credits" %}

6 |
    7 |
  • {% trans "Exercisync is based on the" %} tapiriik {% trans "open source project by" %} Collin Fair
  • 8 |
  • {% trans "Charles Anssens" %} {% trans "for localization/internationalization and integration with DecathlonCoach" %}
  • 9 |
  • {% trans "Timur Bilalov" %} {% trans "for frontend contributions" %}
  • 10 |
  • {% trans "Aerobia, Polar, Strava et al. for creating and supporting the APIs that make this site possible" %}
  • 11 |
  • {% trans "Some icons made by" %} Plus {% trans "from" %} www.flaticon.com {% trans "is licensed by" %} CC 3.0 BY
  • 12 |
  • {% trans "and finally, all the beta testers. You know who you are." %}
  • 13 |
14 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/static/garmin_connect_bad_data.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% block title %}tapiriik uploaded strange activities to my accounts{% endblock %} 3 | {% block content %} 4 |

tapiriik uploaded strange activities to my accounts!

5 |

...what's going on?

6 | 7 |
8 |

Summary: A bug in Garmin Connect exposed test activity data used internally at Garmin. tapiriik couldn't tell these test activities weren't your own activities, so it synchronized them to your other accounts. Please delete these bad activities from all your accounts to avoid future issues.

9 |
10 | 11 |
12 |

Where did these activities come from?

13 |

On June 11, 2019, the interface which tapiriik used to download activities from Garmin Connect started to return incorrect data to tapiriik. Instead of providing a list of your own activities, it began to return test activities from an account used internally at Garmin. As tapiriik could not tell the difference between the test activities and your own, the test activities were uploaded to your other connected accounts as if they were your own.

14 | 15 |

Whose activities are they? Were my activities sent to someone else's account?

16 | 17 |

Everyone affected by this issue received the same set of test activities from the Garmin-internal test account. These test activities were were already publicly available on Garmin Connect, without any log-in required. Your activities were not sent to anyone else's accounts, nor were anyone else's activities sent to your account.

18 | 19 |

For the technically inclined, you can see a slice of the incorrect data that was being returned here (activity list) and here (details of a single activity).

20 | 21 |

Is the issue fixed?

22 | 23 |

Yes and no. The original issue with the Garmin Connect interface has been fixed by Garmin, and additional checks have been added to tapiriik's code to prevent this situation occurring again.

24 | 25 |

However, these bad activities must now be manually deleted from your accounts so that they can not be synchronized any further. Unfortunately, tapiriik can't safely delete the bad activities for you without risking your real data, so this process must be done by hand. Sorry for the inconvenience!

26 | 27 |

Is this a problem with Strava? RunKeeper? Endomondo? etc.?

28 | 29 |

No. The issue originated with Garmin Connect and was propagated to your other accounts via tapiriik. Strava & co. were innocent bystanders to this incident.

30 |
31 | {% endblock %} 32 | -------------------------------------------------------------------------------- /tapiriik/web/templates/static/garmin_connect_users.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% load i18n %} 3 | {% block title %}Who is 'exercisync' on Garmin Connect{% endblock %} 4 | {% block content %} 5 |

Who is 'exercisync'?

6 |

(and how did they get added to my Garmin Connect account?)

7 |

I'm guessing you're here because you just got an email like this:

8 |

9 | 10 |

11 |
    12 |
  • These accounts are automatically managed. You should never have to manually add or remove them as connections.
  • 13 |
  • Never accept a request from these users. If you do receive a connection request (different from the email above), deny it.
  • 14 |
  • Your privacy settings are never changed. It's sad that this needs to be in a FAQ.
  • 15 |
  • You can still stop using exercisync at any time. The user will be removed from your account shortly afterwards.
  • 16 |
17 | 18 |

Here's the technical lowdown:

19 |

exercisync users are quite an active bunch, but the system still spends most of its time checking accounts that haven't been updated since the last synchronization. Having these special accounts allows me to efficiently monitor which users have new activities to be synchronized, and which do not.

20 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/supported-activities.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% block title %}supported activities{% endblock %} 3 | {% block content %} 4 | {% load i18n %} 5 |
6 |

Supported activity types

7 |
    8 |
  • Activity name
    [tag1|tag2|…]
  • 9 | {% with sortedActivities=actMap|dictsortreversed:"name" %} 10 | {% for activity in sortedActivities %} 11 |
  • {{ activity.name }}
    [{% for synonym in activity.synonyms %}{{ synonym|safe }}{% if not forloop.last %}|{% endif %}{% endfor %}]
  • 12 | {% endfor %} 13 | {% endwith %} 14 |
15 |
16 |

{% trans "This list doesn't matter" %}

17 |

{% trans "Do not despair if the activities you want to sync are not on this list, exercisync can still synchronize them. You'll just have to change them to the appropriate activity type once they've arrived at their final destination(s). HR, power, cadence, temperature, and caloric data will always be synchronized when the destination supports it, no matter what type of activity the data is attached to." %}

18 |
19 |
20 |
21 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templates/supported-services-poll.html: -------------------------------------------------------------------------------- 1 | {% extends "site.html" %} 2 | {% block title %}vote for new services{% endblock %} 3 | {% load displayutils %} 4 | {% block content %} 5 | 6 | {% endblock %} -------------------------------------------------------------------------------- /tapiriik/web/templatetags/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Antash/exercisync/313a81ca2c9cf53fcbdc27f8df27bf1007567bcc/tapiriik/web/templatetags/__init__.py -------------------------------------------------------------------------------- /tapiriik/web/templatetags/displayutils.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from django.utils.timesince import timesince 3 | from datetime import datetime, date 4 | import json 5 | register = template.Library() 6 | 7 | @register.filter(name="utctimesince") 8 | def utctimesince(value): 9 | if not value: 10 | return "" 11 | return timesince(value, now=datetime.utcnow()) 12 | 13 | @register.filter(name="fractional_hour_duration") 14 | def fractional_hour_duration(value): 15 | if value is None: 16 | return "" 17 | return "%2.f hours" % (value / 60 / 60) 18 | 19 | @register.filter(name="format_fractional_percentage") 20 | def fractional_percentage(value): 21 | try: 22 | return "%d%%" % round(value * 100) 23 | except: 24 | return "NaN" 25 | 26 | @register.filter(name="format_meters") 27 | def meters_to_kms(value): 28 | try: 29 | return round(value / 1000) 30 | except: 31 | return "NaN" 32 | 33 | @register.filter(name="format_daily_meters_hourly_rate") 34 | def meters_per_day_to_km_per_hour(value): 35 | try: 36 | return (value / 24) / 1000 37 | except: 38 | return "0" 39 | 40 | @register.filter(name="format_seconds_minutes") 41 | def meters_to_kms(value): 42 | try: 43 | return round(value / 60, 3) 44 | except: 45 | return "NaN" 46 | 47 | @register.filter(name='json') 48 | def jsonit(obj): 49 | dthandler = lambda obj: obj.isoformat() if isinstance(obj, datetime) or isinstance(obj, date) else None 50 | return json.dumps(obj, default=dthandler) 51 | 52 | @register.filter(name='dict_get') 53 | def dict_get(tdict, key): 54 | if type(tdict) is not dict: 55 | tdict = tdict.__dict__ 56 | return tdict.get(key, None) 57 | 58 | 59 | @register.filter(name='format') 60 | def format(format, var): 61 | return format.format(var) 62 | 63 | @register.simple_tag 64 | def stringformat(value, *args): 65 | return value.format(*args) 66 | 67 | @register.filter(name="percentage") 68 | def percentage(value, *args): 69 | if not value: 70 | return "NaN" 71 | try: 72 | return str(round(float(value) * 100)) + "%" 73 | except ValueError: 74 | return value 75 | 76 | 77 | def do_infotip(parser, token): 78 | tagname, infotipId = token.split_contents() 79 | nodelist = parser.parse(('endinfotip',)) 80 | parser.delete_first_token() 81 | return InfoTipNode(nodelist, infotipId) 82 | 83 | class InfoTipNode(template.Node): 84 | def __init__(self, nodelist, infotipId): 85 | self.nodelist = nodelist 86 | self.infotipId = infotipId 87 | def render(self, context): 88 | hidden_infotips = context.get('hidden_infotips', None) 89 | if hidden_infotips and self.infotipId in hidden_infotips: 90 | return "" 91 | output = self.nodelist.render(context) 92 | return "

%s

" % (self.infotipId, output) 93 | 94 | register.tag("infotip", do_infotip) -------------------------------------------------------------------------------- /tapiriik/web/templatetags/services.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from tapiriik.services import Service, ServiceRecord 3 | from tapiriik.database import db 4 | register = template.Library() 5 | 6 | 7 | @register.filter(name="svc_ids") 8 | def IDs(value): 9 | return [x["Service"] for x in value] 10 | 11 | 12 | @register.filter(name="svc_providers_except") 13 | def exceptSvc(value): 14 | connections = [y["Service"] for y in value] 15 | return [x for x in Service.List() if x.ID not in connections] 16 | 17 | 18 | 19 | @register.filter(name="svc_populate_conns") 20 | def fullRecords(conns): 21 | return [ServiceRecord(x) for x in db.connections.find({"_id": {"$in": [x["ID"] for x in conns]}})] 22 | -------------------------------------------------------------------------------- /tapiriik/web/templatetags/users.py: -------------------------------------------------------------------------------- 1 | from django import template 2 | from tapiriik.auth import User 3 | register = template.Library() 4 | 5 | 6 | @register.filter(name="has_active_payment") 7 | def HasActivePayment(user): 8 | return User.HasActivePayment(user) 9 | -------------------------------------------------------------------------------- /tapiriik/web/tests.py: -------------------------------------------------------------------------------- 1 | """ 2 | This file demonstrates writing tests using the unittest module. These will pass 3 | when you run "manage.py test". 4 | 5 | Replace this with more appropriate tests for your application. 6 | """ 7 | 8 | from django.test import TestCase 9 | 10 | 11 | class SimpleTest(TestCase): 12 | def test_basic_addition(self): 13 | """ 14 | Tests that 1 + 1 always equals 2. 15 | """ 16 | self.assertEqual(1 + 1, 2) 17 | -------------------------------------------------------------------------------- /tapiriik/web/views.py: -------------------------------------------------------------------------------- 1 | # Create your views here. 2 | -------------------------------------------------------------------------------- /tapiriik/web/views/__init__.py: -------------------------------------------------------------------------------- 1 | from .dashboard import * 2 | from .diagnostics import * 3 | from .auth import * 4 | from .account import * 5 | from .sync import * 6 | from .supported_activities import * 7 | from .supported_services import * 8 | from .payments import * 9 | from .settings import * 10 | from .ab import * 11 | from .activities_dashboard import * 12 | from .rollback import * 13 | from .download import * 14 | # why did I do it this way? should make it less bad 15 | -------------------------------------------------------------------------------- /tapiriik/web/views/ab.py: -------------------------------------------------------------------------------- 1 | from tapiriik.database import db 2 | from django.http import HttpResponse 3 | from django.views.decorators.http import require_POST 4 | import zlib 5 | from datetime import datetime 6 | 7 | 8 | _experiments = {} 9 | 10 | 11 | def ab_register_experiment(key, variants): 12 | _experiments[key] = {"Variants": variants} 13 | 14 | def ab_select_variant(key, userKey): 15 | selector = 0 16 | selector = zlib.adler32(bytes(str(key), "UTF-8"), selector) 17 | selector = zlib.adler32(bytes(str(userKey), "UTF-8"), selector) 18 | selector = selector % len(_experiments[key]["Variants"]) 19 | return _experiments[key]["Variants"][selector] 20 | 21 | def ab_experiment_begin(key, userKey): 22 | db.ab_experiments.insert({"User": userKey, "Experiment": key, "Begin": datetime.utcnow(), "Variant": ab_select_variant(key, userKey)}) 23 | 24 | def ab_user_experiment_begin(key, request): 25 | ab_experiment_begin(key, request.user["_id"]) 26 | 27 | def ab_experiment_complete(key, userKey, result): 28 | active_experiment = db.ab_experiments.find({"User": userKey, "Experiment": key, "Result": {"$exists": False}}, {"_id": 1}).sort("Begin", -1).limit(1)[0] 29 | db.ab_experiments.update({"_id": active_experiment["_id"]}, {"$set": {"Result": result}}) 30 | 31 | def ab_user_experiment_complete(key, request, result): 32 | ab_experiment_complete(key, request.user["_id"], result) 33 | 34 | @require_POST 35 | def ab_web_experiment_begin(request, key): 36 | if not request.user: 37 | return HttpResponse(status=403) 38 | if key not in _experiments: 39 | return HttpResponse(status=404) 40 | ab_user_experiment_begin(key, request) 41 | return HttpResponse() 42 | 43 | def ab_experiment_context(request): 44 | context = {} 45 | if request.user: 46 | for key in _experiments.keys(): 47 | context["ab_%s_%s" % (key, ab_select_variant(key, request.user["_id"]))] = True 48 | return context 49 | -------------------------------------------------------------------------------- /tapiriik/web/views/account.py: -------------------------------------------------------------------------------- 1 | from tapiriik.sync import Sync 2 | from django.http import HttpResponse 3 | from django.views.decorators.http import require_POST 4 | from django.shortcuts import redirect 5 | from tapiriik.auth import User 6 | import json 7 | import dateutil.parser 8 | 9 | 10 | @require_POST 11 | def account_setemail(req): 12 | if not req.user: 13 | return HttpResponse(status=403) 14 | User.SetEmail(req.user, req.POST["email"]) 15 | return redirect("dashboard") 16 | 17 | @require_POST 18 | def account_settimezone(req): 19 | if not req.user: 20 | return HttpResponse(status=403) 21 | User.SetTimezone(req.user, req.POST["timezone"]) 22 | return HttpResponse() 23 | 24 | @require_POST 25 | def account_setconfig(req): 26 | if not req.user: 27 | return HttpResponse(status=403) 28 | data = json.loads(req.body.decode("utf-8")) 29 | if data["sync_skip_before"] and len(data["sync_skip_before"]): 30 | data["sync_skip_before"] = dateutil.parser.parse(data["sync_skip_before"]) 31 | User.SetConfiguration(req.user, data) 32 | Sync.SetNextSyncIsExhaustive(req.user, True) 33 | return HttpResponse() -------------------------------------------------------------------------------- /tapiriik/web/views/activities_dashboard.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render, redirect 2 | from django.http import HttpResponse 3 | from tapiriik.database import db 4 | from tapiriik.settings import WITHDRAWN_SERVICES 5 | import json 6 | import datetime 7 | 8 | def activities_dashboard(req): 9 | if not req.user: 10 | return redirect("/") 11 | return render(req, "activities-dashboard.html") 12 | 13 | def activities_fetch_json(req): 14 | if not req.user: 15 | return HttpResponse(status=403) 16 | 17 | retrieve_fields = [ 18 | "Activities.Prescence", 19 | "Activities.Abscence", 20 | "Activities.Type", 21 | "Activities.Name", 22 | "Activities.StartTime", 23 | "Activities.EndTime", 24 | "Activities.Private", 25 | "Activities.Stationary", 26 | "Activities.FailureCounts" 27 | ] 28 | activityRecords = db.activity_records.find_one({"UserID": req.user["_id"]}, dict([(x, 1) for x in retrieve_fields])) 29 | if not activityRecords: 30 | return HttpResponse("[]", content_type="application/json") 31 | cleanedRecords = [] 32 | for activity in activityRecords["Activities"]: 33 | # Strip down the record since most of this info isn't displayed 34 | for presence in activity["Prescence"]: 35 | del activity["Prescence"][presence]["Exception"] 36 | for abscence in activity["Abscence"]: 37 | if activity["Abscence"][abscence]["Exception"]: 38 | del activity["Abscence"][abscence]["Exception"]["InterventionRequired"] 39 | del activity["Abscence"][abscence]["Exception"]["ClearGroup"] 40 | # Don't really need these seperate at this point 41 | activity["Prescence"].update(activity["Abscence"]) 42 | for svc in WITHDRAWN_SERVICES: 43 | if svc in activity["Prescence"]: 44 | del activity["Prescence"][svc] 45 | del activity["Abscence"] 46 | cleanedRecords.append(activity) 47 | 48 | 49 | dthandler = lambda obj: obj.isoformat() if isinstance(obj, datetime.datetime) or isinstance(obj, datetime.date) else None 50 | 51 | return HttpResponse(json.dumps(cleanedRecords, default=dthandler), content_type="application/json") -------------------------------------------------------------------------------- /tapiriik/web/views/aerobia/__init__.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import redirect, render 2 | from django.http import HttpResponse 3 | from tapiriik.services import Service 4 | from tapiriik.auth import User 5 | import json 6 | 7 | def browse(req): 8 | if req.user is None: 9 | return HttpResponse(status=403) 10 | path = req.GET.get("path", "") 11 | if path == "/": 12 | path = "" 13 | svcRec = User.GetConnectionRecord(req.user, "aerobia") 14 | dbSvc = Service.FromID("aerobia") 15 | dbCl = dbSvc._getClient(svcRec) 16 | 17 | folders = [] 18 | result = dbCl.files_list_folder(path) 19 | while True: 20 | # There's no actual way to filter for folders only :| 21 | folders += [x.path_lower for x in result.entries if not hasattr(x, "rev")] 22 | if result.has_more: 23 | result = dbCl.files_list_folder_continue(result.cursor) 24 | else: 25 | break 26 | 27 | return HttpResponse(json.dumps(sorted(folders)), content_type='application/json') 28 | -------------------------------------------------------------------------------- /tapiriik/web/views/dashboard.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | from django.views.decorators.csrf import ensure_csrf_cookie 3 | 4 | 5 | @ensure_csrf_cookie 6 | def dashboard(req): 7 | return render(req, "dashboard.html") 8 | -------------------------------------------------------------------------------- /tapiriik/web/views/download.py: -------------------------------------------------------------------------------- 1 | from tapiriik.settings import USER_DATA_FILES 2 | from django.http import HttpResponse 3 | from django.views.decorators.csrf import csrf_exempt 4 | 5 | import os.path 6 | 7 | @csrf_exempt 8 | def save_content(req, file_id): 9 | zip_filename = "{}.zip".format(file_id) 10 | file_path = os.path.join(USER_DATA_FILES, zip_filename) 11 | 12 | # if req.method == "POST": 13 | # with open(file_path, 'wb+') as destination: 14 | # for chunk in req.FILES['file'].chunks(): 15 | # destination.write(chunk) 16 | # return HttpResponse(status=200) 17 | #el 18 | if req.method == "GET": 19 | if os.path.isfile(file_path): 20 | zip_file = open(file_path, 'rb') 21 | resp = HttpResponse(zip_file, content_type='application/force-download') 22 | resp['Content-Disposition'] = 'attachment; filename={}'.format(zip_filename) 23 | return resp 24 | 25 | return HttpResponse(status=404) 26 | -------------------------------------------------------------------------------- /tapiriik/web/views/dropbox/__init__.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import redirect, render 2 | from django.http import HttpResponse 3 | from tapiriik.services import Service 4 | from tapiriik.auth import User 5 | import json 6 | 7 | def browse(req): 8 | if req.user is None: 9 | return HttpResponse(status=403) 10 | path = req.GET.get("path", "") 11 | if path == "/": 12 | path = "" 13 | svcRec = User.GetConnectionRecord(req.user, "dropbox") 14 | dbSvc = Service.FromID("dropbox") 15 | dbCl = dbSvc._getClient(svcRec) 16 | 17 | folders = [] 18 | result = dbCl.files_list_folder(path) 19 | while True: 20 | # There's no actual way to filter for folders only :| 21 | folders += [x.path_lower for x in result.entries if not hasattr(x, "rev")] 22 | if result.has_more: 23 | result = dbCl.files_list_folder_continue(result.cursor) 24 | else: 25 | break 26 | 27 | return HttpResponse(json.dumps(sorted(folders)), content_type='application/json') 28 | -------------------------------------------------------------------------------- /tapiriik/web/views/oauth/__init__.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import redirect, render 2 | from django.http import HttpResponse 3 | from django.views.decorators.http import require_POST 4 | from django.views.decorators.csrf import csrf_exempt 5 | from tapiriik.services import Service 6 | from tapiriik.auth import User 7 | import json 8 | 9 | 10 | def authredirect(req, service, level=None): 11 | svc = Service.FromID(service) 12 | return redirect(svc.GenerateUserAuthorizationURL(req.session, level)) 13 | 14 | 15 | def authreturn(req, service, level=None): 16 | if ("error" in req.GET or "not_approved" in req.GET): 17 | success = False 18 | else: 19 | svc = Service.FromID(service) 20 | try: 21 | uid, authData = svc.RetrieveAuthorizationToken(req, level) 22 | except Exception as e: 23 | return render(req, "oauth-failure.html", { 24 | "service": svc, 25 | "error": str(e) 26 | }) 27 | serviceRecord = Service.EnsureServiceRecordWithAuth(svc, uid, authData) 28 | 29 | # auth by this service connection 30 | existingUser = User.AuthByService(serviceRecord) 31 | # only log us in as this different user in the case that we don't already have an account 32 | if req.user is None and existingUser is not None: 33 | User.Login(existingUser, req) 34 | else: 35 | User.Ensure(req) 36 | # link service to user account, possible merge happens behind the scenes (but doesn't effect active user) 37 | User.ConnectService(req.user, serviceRecord) 38 | success = True 39 | 40 | return render(req, "oauth-return.html", {"success": 1 if success else 0}) 41 | 42 | -------------------------------------------------------------------------------- /tapiriik/web/views/privacy.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | from tapiriik.services import Service 3 | from tapiriik.settings import WITHDRAWN_SERVICES, SOFT_LAUNCH_SERVICES 4 | from tapiriik.auth import User 5 | import itertools 6 | 7 | def privacy(request): 8 | 9 | OPTIN = "Opt-in" 10 | NO = "No" 11 | YES = "Yes" 12 | CACHED = "Cached" 13 | SEEBELOW = "See below" 14 | 15 | services = dict([[x.ID, {"DisplayName": x.DisplayName, "ID": x.ID}] for x in Service.List()]) 16 | 17 | services["garminconnect"].update({"email": OPTIN, "password": OPTIN, "tokens": NO, "metadata": YES, "data":NO}) 18 | services["strava"].update({"email": NO, "password": NO, "tokens": YES, "metadata": YES, "data":NO}) 19 | #services["sporttracks"].update({"email": NO, "password": NO, "tokens": YES, "metadata": YES, "data":NO}) 20 | services["dropbox"].update({"email": NO, "password": NO, "tokens": YES, "metadata": YES, "data":CACHED}) 21 | #services["runkeeper"].update({"email": NO, "password": NO, "tokens": YES, "metadata": YES, "data":NO}) 22 | services["rwgps"].update({"email": OPTIN, "password": OPTIN, "tokens": NO, "metadata": YES, "data":NO}) 23 | #services["trainingpeaks"].update({"email": NO, "password": NO, "tokens": YES, "metadata": YES, "data":NO}) 24 | #services["endomondo"].update({"email": NO, "password": NO, "tokens": YES, "metadata": YES, "data":NO}) 25 | #services["motivato"].update({"email": OPTIN, "password": OPTIN, "tokens": NO, "metadata": YES, "data":NO}) 26 | #services["nikeplus"].update({"email": OPTIN, "password": OPTIN, "tokens": NO, "metadata": YES, "data":NO}) 27 | #services["velohero"].update({"email": OPTIN, "password": OPTIN, "tokens": NO, "metadata": YES, "data":NO}) 28 | #services["runsense"].update({"email": NO, "password": NO, "tokens": YES, "metadata": YES, "data":NO}) 29 | #services["trainerroad"].update({"email": OPTIN, "password": OPTIN, "tokens": NO, "metadata": YES, "data":NO}) 30 | services["smashrun"].update({"email": NO, "password": NO, "tokens": YES, "metadata": YES, "data":NO}) 31 | services["beginnertriathlete"].update({"email": OPTIN, "password": OPTIN, "tokens": NO, "metadata": YES, "data": NO}) 32 | #services["trainasone"].update({"email": NO, "password": NO, "tokens": YES, "metadata": YES, "data":NO}) 33 | #services["pulsstory"].update({"email": NO, "password": NO, "tokens": YES, "metadata": YES, "data":NO}) 34 | #services["setio"].update({"email": NO, "password": NO, "tokens": YES, "metadata": YES, "data":NO}) 35 | #services["singletracker"].update({"email": NO, "password": NO, "tokens": YES, "metadata": YES, "data":NO}) 36 | services["aerobia"].update({"email": OPTIN, "password": OPTIN, "tokens": NO, "metadata": YES, "data":NO}) 37 | services["polarflow"].update({"email": NO, "password": NO, "tokens": YES, "metadata": YES, "data":NO}) 38 | services["decathloncoach"].update({"email": NO, "password": NO, "tokens": YES, "metadata": YES, "data":NO}) 39 | services["polarpersonaltrainer"].update({"email": YES, "password": YES, "tokens": NO, "metadata": YES, "data":NO}) 40 | 41 | for svc_id in itertools.chain(WITHDRAWN_SERVICES, SOFT_LAUNCH_SERVICES): 42 | if svc_id in services: 43 | del services[svc_id] 44 | 45 | services_list = sorted(services.values(), key=lambda service: service["ID"]) 46 | return render(request, "privacy.html", {"services": services_list}) 47 | -------------------------------------------------------------------------------- /tapiriik/web/views/rollback.py: -------------------------------------------------------------------------------- 1 | from tapiriik.services.rollback import RollbackTask 2 | from django.http import HttpResponse 3 | from django.views.decorators.http import require_GET 4 | from django.shortcuts import redirect, render 5 | 6 | def account_rollback_initiate(req): 7 | if not req.user: 8 | return HttpResponse(status=403) 9 | 10 | task = RollbackTask.Create(req.user) 11 | 12 | return HttpResponse(task.json()) 13 | 14 | def account_rollback_status(req, task_id): 15 | if not req.user: 16 | return HttpResponse(status=403) 17 | task = RollbackTask.Get(task_id) 18 | 19 | if not task: 20 | return HttpResponse(status=404) 21 | 22 | if req.method == 'POST': 23 | task.Schedule() 24 | return HttpResponse(task.json()) 25 | 26 | def rollback_dashboard(req): 27 | if not req.user: 28 | return redirect('/') 29 | return render(req, "rollback.html") -------------------------------------------------------------------------------- /tapiriik/web/views/settings.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render, redirect 2 | from tapiriik.auth import User 3 | 4 | def settings(request): 5 | available_settings = { 6 | "allow_activity_flow_exception_bypass_via_self": 7 | {"Title": "Route activities via", 8 | "Description": "Allows activities to flow through this service to avoid a flow exception that would otherwise prevent them arriving at a destination.", 9 | "Field": "checkbox" 10 | }, 11 | "sync_private": 12 | {"Title": "Sync private activities", 13 | "Description": "By default, all activities will be synced. Unsetting this will prevent private activities being taken from this service.", 14 | "Field": "checkbox", 15 | "Available": ["strava", "runkeeper"] 16 | } 17 | } 18 | conns = User.GetConnectionRecordsByUser(request.user) 19 | 20 | for key, setting in available_settings.items(): 21 | available_settings[key]["Values"] = {} 22 | 23 | for conn in conns: 24 | config = conn.GetConfiguration() 25 | for key, setting in available_settings.items(): 26 | if request.method == "POST": 27 | formkey = key + "_" + conn.Service.ID 28 | if setting["Field"] == "checkbox": 29 | config[key] = formkey in request.POST 30 | available_settings[key]["Values"][conn.Service.ID] = config[key] 31 | 32 | if request.method == "POST": 33 | conn.SetConfiguration(config) 34 | if request.method == "POST": 35 | return redirect("settings_panel") 36 | 37 | return render(request, "settings.html", {"user": request.user, "settings": available_settings}) 38 | -------------------------------------------------------------------------------- /tapiriik/web/views/supported_activities.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render 2 | 3 | 4 | def supported_activities(req): 5 | # so as not to force people to read REGEX to understand what they can name their activities 6 | ELLIPSES = "…" 7 | activities = {} 8 | activities["Running"] = ["run", "running"] 9 | activities["Cycling"] = ["cycling", "cycle", "bike", "biking"] 10 | activities["Mountain biking"] = ["mtnbiking", "mtnbiking", "mountainbike", "mountainbiking"] 11 | activities["Walking"] = ["walking", "walk"] 12 | activities["Hiking"] = ["hike", "hiking"] 13 | activities["Downhill skiing"] = ["downhill", "downhill skiing", "downhill-skiing", "downhillskiing", ELLIPSES] 14 | activities["Cross-country skiing"] = ["xcskiing", "xc-skiing", "xc-ski", "crosscountry-skiing", ELLIPSES] 15 | activities["Roller skiing"] = ["rollerskiing"] 16 | activities["Snowboarding"] = ["snowboarding", "snowboard"] 17 | activities["Skating"] = ["skate", "skating"] 18 | activities["Swimming"] = ["swim", "swimming"] 19 | activities["Wheelchair"] = ["wheelchair"] 20 | activities["Rowing"] = ["rowing", "row"] 21 | activities["Elliptical"] = ["elliptical"] 22 | activities["Climbing"] = ["climb", "climbing"] 23 | activities["Strength Training"] = ["strength", "strength training"] 24 | activities["Gym"] = ["gym", "workout"] 25 | activities["Stand-up paddling"] = ["sup", "stand-up paddling", "standup paddling", "standuppaddling"] 26 | activities["Other"] = ["other", "unknown"] 27 | activityList = [] 28 | for act, synonyms in activities.items(): 29 | activityList.append({"name": act, "synonyms": synonyms}) 30 | return render(req, "supported-activities.html", {"actMap": activityList}) 31 | -------------------------------------------------------------------------------- /tapiriik/web/views/supported_services.py: -------------------------------------------------------------------------------- 1 | from django.shortcuts import render, redirect 2 | 3 | def supported_services_poll(req): 4 | return render(req, "supported-services-poll.html", {"voter_key": req.user["_id"] if req.user else ""}) # Should probably do something with ancestor accounts? -------------------------------------------------------------------------------- /tapiriik/wsgi.py: -------------------------------------------------------------------------------- 1 | """ 2 | WSGI config for tapiriik project. 3 | 4 | This module contains the WSGI application used by Django's development server 5 | and any production WSGI deployments. It should expose a module-level variable 6 | named ``application``. Django's ``runserver`` and ``runfcgi`` commands discover 7 | this application via the ``WSGI_APPLICATION`` setting. 8 | 9 | Usually you will have the standard Django WSGI application here, but it also 10 | might make sense to replace the whole Django WSGI application with a custom one 11 | that later delegates to the Django one. For example, you could introduce WSGI 12 | middleware here, or combine a Django application with an application of another 13 | framework. 14 | 15 | """ 16 | import os 17 | 18 | # We defer to a DJANGO_SETTINGS_MODULE already in the environment. This breaks 19 | # if running multiple sites in the same mod_wsgi process. To fix this, use 20 | # mod_wsgi daemon mode with each site in its own daemon process, or use 21 | # os.environ["DJANGO_SETTINGS_MODULE"] = "tapiriik.settings" 22 | os.environ.setdefault("DJANGO_SETTINGS_MODULE", "tapiriik.settings") 23 | 24 | # This application object is used by any WSGI server configured to use this 25 | # file. This includes Django's development server, if the WSGI_APPLICATION 26 | # setting points here. 27 | from django.core.wsgi import get_wsgi_application 28 | application = get_wsgi_application() 29 | 30 | # Apply WSGI middleware here. 31 | # from helloworld.wsgi import HelloWorldApplication 32 | # application = HelloWorldApplication(application) 33 | -------------------------------------------------------------------------------- /tz_ingest.py: -------------------------------------------------------------------------------- 1 | # This file isn't called in normal operation, just to update the TZ boundary DB. 2 | # Should be called with `tz_world.*` files from http://efele.net/maps/tz/world/ in the working directory. 3 | # Requires pyshp and shapely for py3k (from https://github.com/mwtoews/shapely/tree/py3) 4 | 5 | import shapefile 6 | from shapely.geometry import Polygon, mapping 7 | import pymongo 8 | from tapiriik.database import tzdb 9 | 10 | print("Dropping boundaries collection") 11 | tzdb.drop_collection("boundaries") 12 | 13 | print("Setting up index") 14 | tzdb.boundaries.ensure_index([("Boundary", pymongo.GEOSPHERE)]) 15 | 16 | print("Reading shapefile") 17 | records = [] 18 | sf = shapefile.Reader("tz_world.shp") 19 | shapeRecs = sf.shapeRecords() 20 | 21 | ct = 0 22 | total = len(shapeRecs) 23 | for shape in shapeRecs: 24 | tzid = shape.record[0] 25 | print("%3d%% %s" % (round(ct * 100 / total), tzid)) 26 | ct += 1 27 | polygon = Polygon(list(shape.shape.points)) 28 | if not polygon.is_valid: 29 | polygon = polygon.buffer(0) # Resolves issues with most self-intersecting geometry 30 | assert polygon.is_valid 31 | record = {"TZID": tzid, "Boundary": mapping(polygon)} 32 | tzdb.boundaries.insert(record) # Would be bulk insert, but that makes it a pain to debug geometry issues 33 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | var path = require("path") 2 | var webpack = require('webpack') 3 | var BundleTracker = require('webpack-bundle-tracker') 4 | 5 | module.exports = { 6 | context: __dirname, 7 | 8 | entry: { 9 | exercisyncApp: './tapiriik/frontend/app', 10 | }, 11 | 12 | output: { 13 | path: path.resolve('./tapiriik/web/static/js/bundles/'), 14 | filename: "[name].js" 15 | }, 16 | 17 | plugins: [ 18 | new BundleTracker({ filename: './webpack-stats.json' }), 19 | new webpack.HotModuleReplacementPlugin() 20 | ], 21 | 22 | module: { 23 | rules: [ 24 | { 25 | test: /\.jsx?$/, 26 | exclude: /node_modules/, 27 | use: ['babel-loader'] 28 | }, 29 | { 30 | test: /\.css$/, 31 | loader: 'style-loader!css-loader' 32 | }, 33 | ], 34 | }, 35 | 36 | resolve: { 37 | modules: ['node_modules'], 38 | extensions: ['.js', '.jsx'] 39 | } 40 | } --------------------------------------------------------------------------------